Bug 1959662 - Allow automatic reauthentication to FedCM - r=anti-tracking-reviewers,timhuang

Differential Revision: https://phabricator.services.mozilla.com/D246230
This commit is contained in:
Benjamin VanderSloot
2025-05-18 23:57:39 +00:00
committed by bvandersloot@mozilla.com
parent dcb661da9b
commit 501d8ea86c
18 changed files with 314 additions and 95 deletions

View File

@@ -387,8 +387,8 @@ IdentityCredential::GetCredentialInMainProcess(
// If we have no collectable credentials, discover a remote
// credential
if (aResult.Length() == 0) {
DiscoverFromExternalSourceInMainProcess(principal, cbc,
aOptions)
DiscoverFromExternalSourceInMainProcess(
principal, cbc, aOptions, aMediationRequirement)
->Then(
GetCurrentSerialEventTarget(), __func__,
[result](const IPCIdentityCredential& credential) {
@@ -418,7 +418,8 @@ IdentityCredential::GetCredentialInMainProcess(
} else {
// If we don't have lightweight credentials enabled, just fire discovery
// off.
DiscoverFromExternalSourceInMainProcess(principal, cbc, aOptions)
DiscoverFromExternalSourceInMainProcess(principal, cbc, aOptions,
aMediationRequirement)
->Then(
GetCurrentSerialEventTarget(), __func__,
[result](const IPCIdentityCredential& credential) {
@@ -1010,7 +1011,8 @@ IdentityCredential::DiscoverLightweightFromExternalSourceInMainProcess(
RefPtr<IdentityCredential::GetIPCIdentityCredentialPromise>
IdentityCredential::DiscoverFromExternalSourceInMainProcess(
nsIPrincipal* aPrincipal, CanonicalBrowsingContext* aBrowsingContext,
const IdentityCredentialRequestOptions& aOptions) {
const IdentityCredentialRequestOptions& aOptions,
const CredentialMediationRequirement& aMediationRequirement) {
MOZ_ASSERT(XRE_IsParentProcess());
MOZ_ASSERT(aPrincipal);
MOZ_ASSERT(aBrowsingContext);
@@ -1100,6 +1102,18 @@ IdentityCredential::DiscoverFromExternalSourceInMainProcess(
const Sequence<MozPromise<IdentityProviderAPIConfig, nsresult,
true>::ResolveOrRejectValue>
resultsSequence(std::move(results));
// If we can skip the provider check, because there is only one
// option and it is already linked, do so!
Maybe<IdentityProviderRequestOptionsWithManifest>
autoSelectedIdentityProvider =
SkipAccountChooser(aOptions.mProviders, resultsSequence);
if (autoSelectedIdentityProvider.isSome()) {
return GetIdentityProviderRequestOptionsWithManifestPromise::
CreateAndResolve(autoSelectedIdentityProvider.extract(),
__func__);
}
// The user picks from the providers
return PromptUserToSelectProvider(
browsingContext, aOptions.mProviders, resultsSequence);
@@ -1111,7 +1125,7 @@ IdentityCredential::DiscoverFromExternalSourceInMainProcess(
})
->Then(
GetCurrentSerialEventTarget(), __func__,
[principal,
[aMediationRequirement, principal,
browsingContext](const IdentityProviderRequestOptionsWithManifest&
providerAndManifest) {
IdentityProviderAPIConfig manifest;
@@ -1119,7 +1133,8 @@ IdentityCredential::DiscoverFromExternalSourceInMainProcess(
std::tie(provider, manifest) = providerAndManifest;
return IdentityCredential::
CreateHeavyweightCredentialDuringDiscovery(
principal, browsingContext, provider, manifest);
principal, browsingContext, provider, manifest,
aMediationRequirement);
},
[](nsresult error) {
return IdentityCredential::GetIPCIdentityCredentialPromise::
@@ -1145,12 +1160,98 @@ IdentityCredential::DiscoverFromExternalSourceInMainProcess(
return result;
}
// static
Maybe<IdentityCredential::IdentityProviderRequestOptionsWithManifest>
IdentityCredential::SkipAccountChooser(
const Sequence<IdentityProviderRequestOptions>& aProviders,
const Sequence<GetManifestPromise::ResolveOrRejectValue>& aManifests) {
if (aProviders.Length() != 1) {
return Nothing();
}
if (aManifests.Length() != 1) {
return Nothing();
}
if (!aManifests.ElementAt(0).IsResolve()) {
return Nothing();
}
const IdentityProviderRequestOptions& resolvedProvider =
aProviders.ElementAt(0);
const IdentityProviderAPIConfig& resolvedManifest =
aManifests.ElementAt(0).ResolveValue();
return Some(std::make_tuple(resolvedProvider, resolvedManifest));
}
// static
Maybe<IdentityProviderAccount> IdentityCredential::FindAccountToReauthenticate(
const IdentityProviderRequestOptions& aProvider, nsIPrincipal* aRPPrincipal,
const IdentityProviderAccountList& aAccountList) {
if (!aAccountList.mAccounts.WasPassed()) {
return Nothing();
}
nsresult rv;
nsCOMPtr<nsIIdentityCredentialStorageService> icStorageService =
mozilla::components::IdentityCredentialStorageService::Service(&rv);
if (NS_WARN_IF(!icStorageService)) {
return Nothing();
}
Maybe<IdentityProviderAccount> result = Nothing();
for (const IdentityProviderAccount& account :
aAccountList.mAccounts.Value()) {
// Don't reauthenticate accounts that have an approved clients list but no
// matching clientID from navigator.credentials.get's argument
if (account.mApproved_clients.WasPassed()) {
if (!aProvider.mClientId.WasPassed() ||
!account.mApproved_clients.Value().Contains(
NS_ConvertUTF8toUTF16(aProvider.mClientId.Value()))) {
continue;
}
}
RefPtr<nsIURI> configURI;
nsresult rv =
NS_NewURI(getter_AddRefs(configURI), aProvider.mConfigURL.Value());
if (NS_FAILED(rv)) {
continue;
}
nsCOMPtr<nsIPrincipal> idpPrincipal = BasePrincipal::CreateContentPrincipal(
configURI, aRPPrincipal->OriginAttributesRef());
// Don't reauthenticate unconnected accounts
bool connected = false;
rv = icStorageService->Connected(aRPPrincipal, idpPrincipal, &connected);
if (NS_WARN_IF(NS_FAILED(rv)) || !connected) {
continue;
}
// Don't reauthenticate if silent access is disabled
bool silentAllowed = false;
rv = CanSilentlyCollect(aRPPrincipal, idpPrincipal, &silentAllowed);
if (!NS_WARN_IF(NS_FAILED(rv)) && !silentAllowed) {
continue;
}
// We only auto-reauthenticate if we have one candidate.
if (result.isSome()) {
return Nothing();
}
// Remember our first candidate so we can return it after
// this loop, or return nothing if we find another!
result = Some(account);
}
return result;
}
// static
RefPtr<IdentityCredential::GetIPCIdentityCredentialPromise>
IdentityCredential::CreateHeavyweightCredentialDuringDiscovery(
nsIPrincipal* aPrincipal, BrowsingContext* aBrowsingContext,
const IdentityProviderRequestOptions& aProvider,
const IdentityProviderAPIConfig& aManifest) {
const IdentityProviderAPIConfig& aManifest,
const CredentialMediationRequirement& aMediationRequirement) {
MOZ_ASSERT(XRE_IsParentProcess());
MOZ_ASSERT(aPrincipal);
MOZ_ASSERT(aBrowsingContext);
@@ -1162,7 +1263,8 @@ IdentityCredential::CreateHeavyweightCredentialDuringDiscovery(
aManifest)
->Then(
GetCurrentSerialEventTarget(), __func__,
[argumentPrincipal, browsingContext, aProvider](
[argumentPrincipal, browsingContext, aManifest, aMediationRequirement,
aProvider](
const std::tuple<IdentityProviderAPIConfig,
IdentityProviderAccountList>& promiseResult) {
IdentityProviderAPIConfig currentManifest;
@@ -1230,6 +1332,21 @@ IdentityCredential::CreateHeavyweightCredentialDuringDiscovery(
});
}
// If we can skip showing the user any UI by just doing a silent
// renewal, do so.
if (aMediationRequirement !=
CredentialMediationRequirement::Required) {
Maybe<IdentityProviderAccount> reauthenticatingAccount =
FindAccountToReauthenticate(aProvider, argumentPrincipal,
accountList);
if (reauthenticatingAccount.isSome()) {
return GetAccountPromise::CreateAndResolve(
std::make_tuple(aManifest,
reauthenticatingAccount.extract()),
__func__);
}
}
return PromptUserToSelectAccount(browsingContext, accountList,
aProvider, currentManifest);
},

View File

@@ -128,6 +128,15 @@ class IdentityCredential final : public Credential {
nsIPrincipal* aIDPPrincipal,
bool* aResult);
static Maybe<IdentityProviderAccount> FindAccountToReauthenticate(
const IdentityProviderRequestOptions& aProvider,
nsIPrincipal* aRPPrincipal,
const IdentityProviderAccountList& aAccountList);
static Maybe<IdentityProviderRequestOptionsWithManifest> SkipAccountChooser(
const Sequence<IdentityProviderRequestOptions>& aProviders,
const Sequence<GetManifestPromise::ResolveOrRejectValue>& aManifests);
static RefPtr<GenericPromise> AllowedToCollectCredential(
nsIPrincipal* aPrincipal, CanonicalBrowsingContext* aBrowsingContext,
const IdentityCredentialRequestOptions& aOptions,
@@ -166,7 +175,8 @@ class IdentityCredential final : public Credential {
static RefPtr<GetIPCIdentityCredentialPromise>
DiscoverFromExternalSourceInMainProcess(
nsIPrincipal* aPrincipal, CanonicalBrowsingContext* aBrowsingContext,
const IdentityCredentialRequestOptions& aOptions);
const IdentityCredentialRequestOptions& aOptions,
const CredentialMediationRequirement& aMediationRequirement);
static RefPtr<GetIPCIdentityCredentialPromise>
DiscoverLightweightFromExternalSourceInMainProcess(
@@ -193,7 +203,8 @@ class IdentityCredential final : public Credential {
CreateHeavyweightCredentialDuringDiscovery(
nsIPrincipal* aPrincipal, BrowsingContext* aBrowsingContext,
const IdentityProviderRequestOptions& aProvider,
const IdentityProviderAPIConfig& aManifest);
const IdentityProviderAPIConfig& aManifest,
const CredentialMediationRequirement& aMediationRequirement);
// Performs a Fetch for the root manifest of the provided identity provider
// if needed and validates its structure. The returned promise resolves

View File

@@ -20,6 +20,8 @@ support-files = [
"server_manifest.json^headers^",
]
["browser_autoreauthn_doesnt_show_ui.js"]
["browser_close_prompt_on_timeout.js"]
["browser_disconnect.js"]

View File

@@ -0,0 +1,148 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
XPCOMUtils.defineLazyServiceGetter(
this,
"IdentityCredentialStorageService",
"@mozilla.org/browser/identity-credential-storage-service;1",
"nsIIdentityCredentialStorageService"
);
const TEST_URL = "https://example.com/";
add_task(async function test_auto_reauthentication_doesnt_show_ui() {
const idpPrincipal = Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI("https://example.net"),
{}
);
const rpPrincipal = Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI("https://example.com"),
{}
);
let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
let unlinked = await SpecialPowers.spawn(
tab.linkedBrowser,
[],
async function () {
let promise = content.navigator.credentials.get({
identity: {
mode: "passive",
providers: [
{
configURL:
"https://example.net/browser/dom/credentialmanagement/identity/tests/browser/server_manifest.json",
clientId: "123",
nonce: "nonce",
},
],
},
});
try {
let cred = await promise;
return cred.token;
} catch (err) {
return err;
}
}
);
ok(unlinked, "expect a result from the second request.");
ok(unlinked.name, "expect a DOMException which must have a name.");
// Set account as registered
IdentityCredentialStorageService.setState(
rpPrincipal,
idpPrincipal,
"connected",
true,
false
);
Services.perms.addFromPrincipal(
rpPrincipal,
"credential-allow-silent-access",
Ci.nsIPermissionManager.ALLOW_ACTION,
Ci.nsIPermissionManager.EXPIRE_SESSION
);
Services.perms.addFromPrincipal(
rpPrincipal,
"credential-allow-silent-access^" + idpPrincipal.origin,
Ci.nsIPermissionManager.ALLOW_ACTION,
Ci.nsIPermissionManager.EXPIRE_SESSION
);
let popupShown = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popupshown"
);
let notApprovedPromise = SpecialPowers.spawn(
tab.linkedBrowser,
[],
async function () {
let promise = content.navigator.credentials.get({
identity: {
mode: "passive",
providers: [
{
configURL:
"https://example.net/browser/dom/credentialmanagement/identity/tests/browser/server_manifest.json",
nonce: "nonce",
},
],
},
});
try {
let cred = await promise;
return cred.token;
} catch (err) {
return err;
}
}
);
await popupShown;
tab.linkedBrowser.browsingContext.topChromeWindow.document
.getElementsByClassName("popup-notification-secondary-button")[0]
.click();
let notApproved = await notApprovedPromise;
ok(notApproved, "expect a result from the second request.");
ok(notApproved.name, "expect a DOMException which must have a name.");
let approvedAndLinked = await SpecialPowers.spawn(
tab.linkedBrowser,
[],
async function () {
let promise = content.navigator.credentials.get({
identity: {
mode: "passive",
providers: [
{
configURL:
"https://example.net/browser/dom/credentialmanagement/identity/tests/browser/server_manifest.json",
clientId: "123",
nonce: "nonce",
},
],
},
});
try {
let cred = await promise;
return cred.token;
} catch (err) {
return err;
}
}
);
is(approvedAndLinked, "result", "Result obtained!");
// Close tabs.
await BrowserTestUtils.removeTab(tab);
await SpecialPowers.popPrefEnv();
});

View File

@@ -1,8 +1,3 @@
[fedcm-userinfo-after-resolve.https.html]
expected:
if os == "android": OK
TIMEOUT
[Test getUserInfo() after resolve() to verify that resolve stores the RP/IDP connection]
expected:
if os == "android": FAIL
TIMEOUT
expected: FAIL

View File

@@ -1,8 +1,3 @@
[resolve-after-preventsilentaccess.https.html]
expected:
if os == "android": OK
TIMEOUT
[Test that resolve clears the preventSilentAccess state.]
expected:
if os == "android": FAIL
TIMEOUT
expected: FAIL

View File

@@ -1,8 +1,3 @@
[fedcm-auto-reauthn-without-approved-clients.https.html]
expected:
if os == "android": OK
TIMEOUT
[Test that if the clientId is missing from approved_clients then autoreauthentication cannot occur.]
expected:
if os == "android": FAIL
TIMEOUT
expected: FAIL

View File

@@ -1,8 +1,3 @@
[fedcm-auto-selected-flag.https.html]
expected:
if os == "android": OK
TIMEOUT
[Test that the is_auto_selected bit is properly sent.]
expected:
if os == "android": FAIL
TIMEOUT
expected: FAIL

View File

@@ -4,7 +4,7 @@
expected: NOTRUN
[Test that the active mode succeeds with user activation.]
expected: NOTRUN
expected: TIMEOUT
[Test that the active mode without user activation will fail.]
expected: TIMEOUT
expected: FAIL

View File

@@ -1,22 +1,22 @@
[fedcm-disconnect.sub.https.html]
expected: TIMEOUT
[Repeatedly calling disconnect should eventually fail]
expected: TIMEOUT
expected: [FAIL, TIMEOUT, NOTRUN]
[Test that disconnect fails when there is no account to disconnect]
expected: NOTRUN
expected: [FAIL, TIMEOUT, NOTRUN]
[Test that disconnect succeeds when there is an account to disconnect]
expected: NOTRUN
expected: [FAIL, TIMEOUT, NOTRUN]
[Test that disconnecting the same account twice results in failure.]
expected: NOTRUN
expected: [FAIL, TIMEOUT, NOTRUN]
[Disconnect passing an incorrect ID can still succeed]
expected: NOTRUN
expected: [FAIL, TIMEOUT, NOTRUN]
[Disconnect is bound to each IDP]
expected: NOTRUN
expected: [FAIL, TIMEOUT, NOTRUN]
[Test that disconnect succeeds when there is a pending get request and the get request succeeds after the disconnect]
expected: NOTRUN
expected: [FAIL, TIMEOUT, NOTRUN]

View File

@@ -1,8 +1,3 @@
[fedcm-login-status-unknown.https.html]
expected:
if os == "android": OK
TIMEOUT
[Test that promise is rejected silently when accounts fetch fails in unknown state]
expected:
if os == "android": FAIL
TIMEOUT
expected: FAIL

View File

@@ -1,8 +1,3 @@
[fedcm-manifest-not-in-list.https.html]
expected:
if os == "android": OK
TIMEOUT
[Test that the promise is rejected if the manifest is not in the manifest list]
expected:
if os == "android": FAIL
TIMEOUT
expected: FAIL

View File

@@ -1,8 +1,3 @@
[fedcm-opaque-rp-origin.https.html]
expected:
if os == "android": OK
TIMEOUT
[Opaque RP origin should trigger a NetworkError.]
expected:
if os == "android": FAIL
TIMEOUT
expected: FAIL

View File

@@ -1,8 +1,3 @@
[fedcm-pending-userinfo.https.html]
expected:
if os == "android": OK
TIMEOUT
[Test basic User InFo API flow]
expected:
if os == "android": FAIL
TIMEOUT
expected: FAIL

View File

@@ -1,8 +1,3 @@
[fedcm-returning-account-auto-reauthn.https.html]
expected:
if os == "android": OK
TIMEOUT
[Test that the returning account from the two accounts will be auto re-authenticated.]
expected:
if os == "android": FAIL
TIMEOUT
expected: FAIL

View File

@@ -1,4 +1,3 @@
[fedcm-same-site-none.https.html]
expected: TIMEOUT
[FedCM requests should be considered cross-origin and therefore not send SameSite=Strict or Lax cookies.]
expected: TIMEOUT
expected: FAIL

View File

@@ -1,18 +1,9 @@
[fedcm-userinfo.https.html]
expected:
if os == "android": OK
TIMEOUT
[Test basic User InFo API flow]
expected:
if os == "android": FAIL
TIMEOUT
expected: FAIL
[Test that User Info API only works when invoked from iframe that is same origin as the IDP]
expected:
if os == "android": FAIL
NOTRUN
expected: FAIL
[Test that User Info API does not work in the top frame]
expected:
if os == "android": FAIL
NOTRUN
expected: FAIL

View File

@@ -1,9 +1,6 @@
[lfedcm-identity.discovery.tentative.sub.https.html]
expected:
if fission and (os == "linux") and not tsan and not asan: [TIMEOUT, OK]
if fission and (os == "win") and not debug: TIMEOUT
if not fission: OK
[OK, TIMEOUT, ERROR]
[TIMEOUT, OK, ERROR]
[Cross-origin identity credential discovery does not resolve with effective store from the wrong origin]
expected:
if not tsan and (os == "linux") and fission and not asan: [NOTRUN, PASS, TIMEOUT]
@@ -23,11 +20,10 @@
[Cross-origin identity credential discovery works using the effectiveQueryURL]
expected:
if (os == "linux") and fission and not asan and not tsan: [TIMEOUT, PASS, NOTRUN]
if (os == "win") and not debug: [PASS, TIMEOUT]
FAIL
[FAIL, TIMEOUT, NOTRUN]
[Cross-origin identity credential discovery works]
expected: FAIL
expected: [FAIL, TIMEOUT, NOTRUN]
[Origin inferred from loginURL for discovery]
expected: FAIL
expected: [FAIL, TIMEOUT, NOTRUN]