@salesforce/ui-bundle-template-feature-angular-authentication 12.7.0 → 12.8.0-canary.1019.37.1.g1f7b157f
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CHANGELOG.md +8 -0
- package/dist/LICENSE.txt +82 -0
- package/dist/force-app/main/default/classes/UIBundleSocialLoginConfig.cls +145 -0
- package/dist/force-app/main/default/classes/UIBundleSocialLoginConfig.cls-meta.xml +5 -0
- package/dist/force-app/main/default/classes/UIBundleSocialLoginConfig_Test.cls +181 -0
- package/dist/force-app/main/default/classes/UIBundleSocialLoginConfig_Test.cls-meta.xml +5 -0
- package/dist/force-app/main/default/uiBundles/feature-angular-authentication/package.json +3 -3
- package/dist/force-app/main/default/uiBundles/feature-angular-authentication/src/app/components/ui/dialog/dialog.html +1 -1
- package/dist/force-app/main/default/uiBundles/feature-angular-authentication/src/app/components/ui/icon/icon.ts +6 -0
- package/dist/force-app/main/default/uiBundles/feature-angular-authentication/src/app/features/authentication/social-login/social-login.html +39 -0
- package/dist/force-app/main/default/uiBundles/feature-angular-authentication/src/app/features/authentication/social-login/social-login.ts +107 -0
- package/dist/force-app/main/default/uiBundles/feature-angular-authentication/src/app/pages/login/login-page.html +2 -0
- package/dist/force-app/main/default/uiBundles/feature-angular-authentication/src/app/pages/login/login-page.ts +2 -1
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/dist/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
## [12.8.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v12.7.0...v12.8.0) (2026-09-23)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
6
14
|
## [12.7.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v12.6.3...v12.7.0) (2026-09-22)
|
|
7
15
|
|
|
8
16
|
|
package/dist/LICENSE.txt
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Terms of Use
|
|
2
|
+
|
|
3
|
+
Copyright 2026 Salesforce, Inc. All rights reserved.
|
|
4
|
+
|
|
5
|
+
These Terms of Use govern the download, installation, and/or use of this
|
|
6
|
+
software provided by Salesforce, Inc. ("Salesforce") (the "Software"), were
|
|
7
|
+
last updated on April 15, 2025, and constitute a legally binding
|
|
8
|
+
agreement between you and Salesforce. If you do not agree to these Terms of
|
|
9
|
+
Use, do not install or use the Software.
|
|
10
|
+
|
|
11
|
+
Salesforce grants you a worldwide, non-exclusive, no-charge, royalty-free
|
|
12
|
+
copyright license to reproduce, prepare derivative works of, publicly
|
|
13
|
+
display, publicly perform, sublicense, and distribute the Software and
|
|
14
|
+
derivative works subject to these Terms. These Terms shall be included in
|
|
15
|
+
all copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
Subject to the limited rights expressly granted hereunder, Salesforce
|
|
18
|
+
reserves all rights, title, and interest in and to all intellectual
|
|
19
|
+
property subsisting in the Software. No rights are granted to you hereunder
|
|
20
|
+
other than as expressly set forth herein. Users residing in countries on
|
|
21
|
+
the United States Office of Foreign Assets Control sanction list, or which
|
|
22
|
+
are otherwise subject to a US export embargo, may not use the Software.
|
|
23
|
+
|
|
24
|
+
Implementation of the Software may require development work, for which you
|
|
25
|
+
are responsible. The Software may contain bugs, errors and
|
|
26
|
+
incompatibilities and is made available on an AS IS basis without support,
|
|
27
|
+
updates, or service level commitments.
|
|
28
|
+
|
|
29
|
+
Salesforce reserves the right at any time to modify, suspend, or
|
|
30
|
+
discontinue, the Software (or any part thereof) with or without notice. You
|
|
31
|
+
agree that Salesforce shall not be liable to you or to any third party for
|
|
32
|
+
any modification, suspension, or discontinuance.
|
|
33
|
+
|
|
34
|
+
You agree to defend Salesforce against any claim, demand, suit or
|
|
35
|
+
proceeding made or brought against Salesforce by a third party arising out
|
|
36
|
+
of or accruing from (a) your use of the Software, and (b) any application
|
|
37
|
+
you develop with the Software that infringes any copyright, trademark,
|
|
38
|
+
trade secret, trade dress, patent, or other intellectual property right of
|
|
39
|
+
any person or defames any person or violates their rights of publicity or
|
|
40
|
+
privacy (each a "Claim Against Salesforce"), and will indemnify Salesforce
|
|
41
|
+
from any damages, attorney fees, and costs finally awarded against
|
|
42
|
+
Salesforce as a result of, or for any amounts paid by Salesforce under a
|
|
43
|
+
settlement approved by you in writing of, a Claim Against Salesforce,
|
|
44
|
+
provided Salesforce (x) promptly gives you written notice of the Claim
|
|
45
|
+
Against Salesforce, (y) gives you sole control of the defense and
|
|
46
|
+
settlement of the Claim Against Salesforce (except that you may not settle
|
|
47
|
+
any Claim Against Salesforce unless it unconditionally releases Salesforce
|
|
48
|
+
of all liability), and (z) gives you all reasonable assistance, at your
|
|
49
|
+
expense.
|
|
50
|
+
|
|
51
|
+
WITHOUT LIMITING THE GENERALITY OF THE FOREGOING, THE SOFTWARE IS NOT
|
|
52
|
+
SUPPORTED AND IS PROVIDED "AS IS," WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
53
|
+
IMPLIED. IN NO EVENT SHALL SALESFORCE HAVE ANY LIABILITY FOR ANY DAMAGES,
|
|
54
|
+
INCLUDING, BUT NOT LIMITED TO, DIRECT, INDIRECT, SPECIAL, INCIDENTAL,
|
|
55
|
+
PUNITIVE, OR CONSEQUENTIAL DAMAGES, OR DAMAGES BASED ON LOST PROFITS, DATA,
|
|
56
|
+
OR USE, IN CONNECTION WITH THE SOFTWARE, HOWEVER CAUSED AND WHETHER IN
|
|
57
|
+
CONTRACT, TORT, OR UNDER ANY OTHER THEORY OF LIABILITY, WHETHER OR NOT YOU
|
|
58
|
+
HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
|
59
|
+
|
|
60
|
+
These Terms of Use shall be governed exclusively by the internal laws of
|
|
61
|
+
the State of California, without regard to its conflicts of laws
|
|
62
|
+
rules. Each party hereby consents to the exclusive jurisdiction of the
|
|
63
|
+
state and federal courts located in San Francisco County, California to
|
|
64
|
+
adjudicate any dispute arising out of or relating to these Terms of Use and
|
|
65
|
+
the download, installation, and/or use of the Software. Except as expressly
|
|
66
|
+
stated herein, these Terms of Use constitute the entire agreement between
|
|
67
|
+
the parties, and supersede all prior and contemporaneous agreements,
|
|
68
|
+
proposals, or representations, written or oral, concerning their subject
|
|
69
|
+
matter. No modification, amendment, or waiver of any provision of these
|
|
70
|
+
Terms of Use shall be effective unless it is by an update to these Terms of
|
|
71
|
+
Use that Salesforce makes available, or is in writing and signed by the
|
|
72
|
+
party against whom the modification, amendment, or waiver is to be
|
|
73
|
+
asserted.
|
|
74
|
+
|
|
75
|
+
Data Privacy: Salesforce may collect, process, and store device,
|
|
76
|
+
system, and other information related to your use of the Software. This
|
|
77
|
+
information includes, but is not limited to, IP address, user metrics, and
|
|
78
|
+
other data ("Usage Data"). Salesforce may use Usage Data for analytics,
|
|
79
|
+
product development, and marketing purposes. You acknowledge that files
|
|
80
|
+
generated in conjunction with the Software may contain sensitive or
|
|
81
|
+
confidential data, and you are solely responsible for anonymizing and
|
|
82
|
+
protecting such data.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST endpoint for Social Login configuration on React Experience Cloud sites.
|
|
3
|
+
*
|
|
4
|
+
* Endpoint: GET /services/apexrest/auth/social-login-config?startURL=/
|
|
5
|
+
*
|
|
6
|
+
* This replicates the behavior of the OOTB community_login/socialLogin LWC:
|
|
7
|
+
* - applauncher.SocialLoginController.getAuthProviders()
|
|
8
|
+
* - applauncher.SocialLoginController.getSamlProviders()
|
|
9
|
+
* - applauncher.SocialLoginController.getCommunityDomainSsoUrl()
|
|
10
|
+
* - applauncher.SocialLoginController.getSamlSsoUrl()
|
|
11
|
+
* - applauncher.SocialLoginController.handleIdp()
|
|
12
|
+
*
|
|
13
|
+
* The OOTB LWC does not render on React (Site Container) sites due to a platform
|
|
14
|
+
* restriction (isSiteContainer check in node-loginsettings.ftl).
|
|
15
|
+
* This endpoint enables a custom React component to achieve the same functionality.
|
|
16
|
+
*
|
|
17
|
+
* @see https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex_class_Auth_AuthConfiguration.htm
|
|
18
|
+
*/
|
|
19
|
+
@RestResource(urlMapping='/auth/social-login-config')
|
|
20
|
+
global with sharing class UIBundleSocialLoginConfig {
|
|
21
|
+
|
|
22
|
+
@HttpGet
|
|
23
|
+
global static SocialLoginConfigResponse getConfig() {
|
|
24
|
+
try {
|
|
25
|
+
// Resolve the post-authentication redirect path, sanitized against
|
|
26
|
+
// open-redirect attacks (CWE-601). See resolveStartUrl.
|
|
27
|
+
String startUrl = resolveStartUrl();
|
|
28
|
+
|
|
29
|
+
String communityUrl = Site.getBaseUrl();
|
|
30
|
+
Auth.AuthConfiguration authConfig = new Auth.AuthConfiguration(communityUrl, startUrl);
|
|
31
|
+
|
|
32
|
+
List<AuthProviderInfo> authProviders = new List<AuthProviderInfo>();
|
|
33
|
+
List<SamlProviderInfo> samlProviders = new List<SamlProviderInfo>();
|
|
34
|
+
String autoRedirectUrl = null;
|
|
35
|
+
|
|
36
|
+
// Fetch OAuth Auth Providers (Google, Facebook, etc.)
|
|
37
|
+
List<AuthProvider> rawAuthProviders = authConfig.getAuthProviders();
|
|
38
|
+
for (AuthProvider ap : rawAuthProviders) {
|
|
39
|
+
String ssoUrl = Auth.AuthConfiguration.getAuthProviderSsoDomainUrl(
|
|
40
|
+
communityUrl, startUrl, ap.DeveloperName
|
|
41
|
+
);
|
|
42
|
+
authProviders.add(new AuthProviderInfo(
|
|
43
|
+
ap.DeveloperName,
|
|
44
|
+
ap.FriendlyName,
|
|
45
|
+
ap.IconUrl,
|
|
46
|
+
ssoUrl
|
|
47
|
+
));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Fetch SAML SSO Providers
|
|
51
|
+
List<SamlSsoConfig> rawSamlProviders = authConfig.getSamlProviders();
|
|
52
|
+
for (SamlSsoConfig saml : rawSamlProviders) {
|
|
53
|
+
String ssoUrl = Auth.AuthConfiguration.getSamlSsoUrl(
|
|
54
|
+
communityUrl, startUrl, saml.Id
|
|
55
|
+
);
|
|
56
|
+
samlProviders.add(new SamlProviderInfo(
|
|
57
|
+
saml.Id,
|
|
58
|
+
saml.MasterLabel,
|
|
59
|
+
ssoUrl
|
|
60
|
+
));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// handleIdp behavior: auto-redirect if exactly one IDP and no username/password login
|
|
64
|
+
Integer totalProviders = rawAuthProviders.size() + rawSamlProviders.size();
|
|
65
|
+
if (totalProviders == 1 && !authConfig.getUsernamePasswordEnabled()) {
|
|
66
|
+
if (!authProviders.isEmpty()) {
|
|
67
|
+
autoRedirectUrl = authProviders[0].ssoUrl;
|
|
68
|
+
} else if (!samlProviders.isEmpty()) {
|
|
69
|
+
autoRedirectUrl = samlProviders[0].ssoUrl;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return new SocialLoginConfigResponse(authProviders, samlProviders, autoRedirectUrl);
|
|
74
|
+
} catch (Exception ex) {
|
|
75
|
+
System.debug(LoggingLevel.ERROR, 'UIBundleSocialLoginConfig error: ' + ex.getMessage());
|
|
76
|
+
return new SocialLoginConfigResponse(
|
|
77
|
+
new List<AuthProviderInfo>(),
|
|
78
|
+
new List<SamlProviderInfo>(),
|
|
79
|
+
null
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolves the post-authentication redirect path from the request and
|
|
86
|
+
* sanitizes it to prevent open-redirect attacks (CWE-601).
|
|
87
|
+
*
|
|
88
|
+
* Delegates to the shared UIBundleAuthUtils.getSanitizedStartUrl — the same
|
|
89
|
+
* validation used by UIBundleLogin and UIBundleRegistration — which
|
|
90
|
+
* URL-decodes the value (catching encoded bypasses such as %2f%2f) and then
|
|
91
|
+
* rejects absolute, protocol-relative ("//"), backslash, "@", ":" and
|
|
92
|
+
* control-character inputs, falling back to the community path prefix. Only
|
|
93
|
+
* safe, community-relative paths survive, prefixed with Site.getPathPrefix()
|
|
94
|
+
* so the redirect lands on this community rather than the org default.
|
|
95
|
+
*/
|
|
96
|
+
@TestVisible
|
|
97
|
+
private static String resolveStartUrl() {
|
|
98
|
+
return UIBundleAuthUtils.getSanitizedStartUrl(
|
|
99
|
+
RestContext.request.params.get('startURL'),
|
|
100
|
+
Site.getPathPrefix()
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
global class SocialLoginConfigResponse {
|
|
105
|
+
global List<AuthProviderInfo> authProviders;
|
|
106
|
+
global List<SamlProviderInfo> samlProviders;
|
|
107
|
+
global String autoRedirectUrl;
|
|
108
|
+
|
|
109
|
+
global SocialLoginConfigResponse(
|
|
110
|
+
List<AuthProviderInfo> authProviders,
|
|
111
|
+
List<SamlProviderInfo> samlProviders,
|
|
112
|
+
String autoRedirectUrl
|
|
113
|
+
) {
|
|
114
|
+
this.authProviders = authProviders;
|
|
115
|
+
this.samlProviders = samlProviders;
|
|
116
|
+
this.autoRedirectUrl = autoRedirectUrl;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
global class AuthProviderInfo {
|
|
121
|
+
global String developerName;
|
|
122
|
+
global String friendlyName;
|
|
123
|
+
global String iconUrl;
|
|
124
|
+
global String ssoUrl;
|
|
125
|
+
|
|
126
|
+
global AuthProviderInfo(String developerName, String friendlyName, String iconUrl, String ssoUrl) {
|
|
127
|
+
this.developerName = developerName;
|
|
128
|
+
this.friendlyName = friendlyName;
|
|
129
|
+
this.iconUrl = iconUrl;
|
|
130
|
+
this.ssoUrl = ssoUrl;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
global class SamlProviderInfo {
|
|
135
|
+
global String id;
|
|
136
|
+
global String masterLabel;
|
|
137
|
+
global String ssoUrl;
|
|
138
|
+
|
|
139
|
+
global SamlProviderInfo(String id, String masterLabel, String ssoUrl) {
|
|
140
|
+
this.id = id;
|
|
141
|
+
this.masterLabel = masterLabel;
|
|
142
|
+
this.ssoUrl = ssoUrl;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test class for UIBundleSocialLoginConfig REST endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Auth.AuthConfiguration requires a valid community URL so these tests
|
|
5
|
+
* exercise the error-handling path and verify the response wrapper structure.
|
|
6
|
+
* Integration-level testing of actual Auth Provider/SAML discovery happens
|
|
7
|
+
* in the target org via the React component E2E flow.
|
|
8
|
+
*/
|
|
9
|
+
@isTest
|
|
10
|
+
private class UIBundleSocialLoginConfig_Test {
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Test that the endpoint returns an empty config when no community context
|
|
14
|
+
* is available (covers the catch block graceful fallback).
|
|
15
|
+
*/
|
|
16
|
+
@isTest
|
|
17
|
+
static void testGetConfigNoSiteContext() {
|
|
18
|
+
RestRequest req = new RestRequest();
|
|
19
|
+
req.requestURI = '/services/apexrest/auth/social-login-config';
|
|
20
|
+
req.httpMethod = 'GET';
|
|
21
|
+
req.params.put('startURL', '/');
|
|
22
|
+
RestContext.request = req;
|
|
23
|
+
RestContext.response = new RestResponse();
|
|
24
|
+
|
|
25
|
+
Test.startTest();
|
|
26
|
+
UIBundleSocialLoginConfig.SocialLoginConfigResponse result =
|
|
27
|
+
UIBundleSocialLoginConfig.getConfig();
|
|
28
|
+
Test.stopTest();
|
|
29
|
+
|
|
30
|
+
// Should return empty lists (graceful fallback), not null
|
|
31
|
+
System.assertNotEquals(null, result, 'Response should not be null');
|
|
32
|
+
System.assertNotEquals(null, result.authProviders, 'authProviders list should not be null');
|
|
33
|
+
System.assertNotEquals(null, result.samlProviders, 'samlProviders list should not be null');
|
|
34
|
+
System.assertEquals(0, result.authProviders.size(), 'authProviders should be empty without community context');
|
|
35
|
+
System.assertEquals(0, result.samlProviders.size(), 'samlProviders should be empty without community context');
|
|
36
|
+
System.assertEquals(null, result.autoRedirectUrl, 'autoRedirectUrl should be null without providers');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Test that the endpoint handles a missing startURL parameter gracefully
|
|
41
|
+
* (defaults to "/").
|
|
42
|
+
*/
|
|
43
|
+
@isTest
|
|
44
|
+
static void testGetConfigNoStartUrl() {
|
|
45
|
+
RestRequest req = new RestRequest();
|
|
46
|
+
req.requestURI = '/services/apexrest/auth/social-login-config';
|
|
47
|
+
req.httpMethod = 'GET';
|
|
48
|
+
// Intentionally not setting startURL param
|
|
49
|
+
RestContext.request = req;
|
|
50
|
+
RestContext.response = new RestResponse();
|
|
51
|
+
|
|
52
|
+
Test.startTest();
|
|
53
|
+
UIBundleSocialLoginConfig.SocialLoginConfigResponse result =
|
|
54
|
+
UIBundleSocialLoginConfig.getConfig();
|
|
55
|
+
Test.stopTest();
|
|
56
|
+
|
|
57
|
+
System.assertNotEquals(null, result, 'Response should not be null even without startURL');
|
|
58
|
+
System.assertNotEquals(null, result.authProviders, 'authProviders should not be null');
|
|
59
|
+
System.assertNotEquals(null, result.samlProviders, 'samlProviders should not be null');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Test the inner class constructors for code coverage.
|
|
64
|
+
*/
|
|
65
|
+
@isTest
|
|
66
|
+
static void testInnerClassConstructors() {
|
|
67
|
+
Test.startTest();
|
|
68
|
+
|
|
69
|
+
UIBundleSocialLoginConfig.AuthProviderInfo authInfo =
|
|
70
|
+
new UIBundleSocialLoginConfig.AuthProviderInfo(
|
|
71
|
+
'TestProvider', 'Test Provider', 'https://example.com/icon.png', 'https://example.com/sso'
|
|
72
|
+
);
|
|
73
|
+
System.assertEquals('TestProvider', authInfo.developerName);
|
|
74
|
+
System.assertEquals('Test Provider', authInfo.friendlyName);
|
|
75
|
+
System.assertEquals('https://example.com/icon.png', authInfo.iconUrl);
|
|
76
|
+
System.assertEquals('https://example.com/sso', authInfo.ssoUrl);
|
|
77
|
+
|
|
78
|
+
UIBundleSocialLoginConfig.SamlProviderInfo samlInfo =
|
|
79
|
+
new UIBundleSocialLoginConfig.SamlProviderInfo(
|
|
80
|
+
'001000000000001', 'Test SAML', 'https://example.com/saml/sso'
|
|
81
|
+
);
|
|
82
|
+
System.assertEquals('001000000000001', samlInfo.id);
|
|
83
|
+
System.assertEquals('Test SAML', samlInfo.masterLabel);
|
|
84
|
+
System.assertEquals('https://example.com/saml/sso', samlInfo.ssoUrl);
|
|
85
|
+
|
|
86
|
+
List<UIBundleSocialLoginConfig.AuthProviderInfo> authList =
|
|
87
|
+
new List<UIBundleSocialLoginConfig.AuthProviderInfo>{ authInfo };
|
|
88
|
+
List<UIBundleSocialLoginConfig.SamlProviderInfo> samlList =
|
|
89
|
+
new List<UIBundleSocialLoginConfig.SamlProviderInfo>{ samlInfo };
|
|
90
|
+
|
|
91
|
+
UIBundleSocialLoginConfig.SocialLoginConfigResponse resp =
|
|
92
|
+
new UIBundleSocialLoginConfig.SocialLoginConfigResponse(
|
|
93
|
+
authList, samlList, 'https://example.com/redirect'
|
|
94
|
+
);
|
|
95
|
+
System.assertEquals(1, resp.authProviders.size());
|
|
96
|
+
System.assertEquals(1, resp.samlProviders.size());
|
|
97
|
+
System.assertEquals('https://example.com/redirect', resp.autoRedirectUrl);
|
|
98
|
+
|
|
99
|
+
Test.stopTest();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- Open-redirect sanitization (CWE-601) -------------------------------
|
|
103
|
+
// resolveStartUrl() is the boundary where the endpoint sanitizes the
|
|
104
|
+
// caller-supplied startURL before it is embedded in the SSO redirect URL.
|
|
105
|
+
// These tests assert malicious values never survive that boundary and that
|
|
106
|
+
// legitimate relative paths do.
|
|
107
|
+
|
|
108
|
+
@isTest
|
|
109
|
+
static void resolveStartUrlRejectsAbsoluteUrl() {
|
|
110
|
+
assertNotReflected('https://evil-attacker.com');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@isTest
|
|
114
|
+
static void resolveStartUrlRejectsProtocolRelativeUrl() {
|
|
115
|
+
assertNotReflected('//evil-attacker.com');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@isTest
|
|
119
|
+
static void resolveStartUrlRejectsBackslashVariant() {
|
|
120
|
+
assertNotReflected('/\\evil-attacker.com');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@isTest
|
|
124
|
+
static void resolveStartUrlRejectsEncodedBypass() {
|
|
125
|
+
assertNotReflected('%2f%2fevil-attacker.com');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@isTest
|
|
129
|
+
static void resolveStartUrlAllowsRelativePath() {
|
|
130
|
+
setRequest('/dashboard');
|
|
131
|
+
String resolved = UIBundleSocialLoginConfig.resolveStartUrl();
|
|
132
|
+
System.assertNotEquals(null, resolved, 'A valid relative path should resolve to a non-null value');
|
|
133
|
+
System.assert(
|
|
134
|
+
resolved.endsWith('/dashboard'),
|
|
135
|
+
'A valid relative path must be preserved (optionally prefixed): ' + resolved
|
|
136
|
+
);
|
|
137
|
+
System.assert(!resolved.startsWith('http'), 'Result must not be absolute: ' + resolved);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@isTest
|
|
141
|
+
static void getConfigWithMaliciousStartUrlReturnsGracefulEmpty() {
|
|
142
|
+
// End-to-end: a malicious startURL must not crash the endpoint; without
|
|
143
|
+
// a real site context it returns the graceful empty response (unchanged).
|
|
144
|
+
setRequest('https://evil-attacker.com');
|
|
145
|
+
|
|
146
|
+
Test.startTest();
|
|
147
|
+
UIBundleSocialLoginConfig.SocialLoginConfigResponse result =
|
|
148
|
+
UIBundleSocialLoginConfig.getConfig();
|
|
149
|
+
Test.stopTest();
|
|
150
|
+
|
|
151
|
+
System.assertNotEquals(null, result, 'Response should not be null');
|
|
152
|
+
System.assertEquals(0, result.authProviders.size(), 'No providers without site context');
|
|
153
|
+
System.assertEquals(0, result.samlProviders.size(), 'No SAML providers without site context');
|
|
154
|
+
System.assertEquals(null, result.autoRedirectUrl, 'No auto-redirect without providers');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---- helpers ------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/** Asserts a malicious startURL value is not reflected by resolveStartUrl(). */
|
|
160
|
+
private static void assertNotReflected(String maliciousStartUrl) {
|
|
161
|
+
setRequest(maliciousStartUrl);
|
|
162
|
+
String resolved = UIBundleSocialLoginConfig.resolveStartUrl();
|
|
163
|
+
String safe = resolved == null ? '' : resolved;
|
|
164
|
+
System.assert(!safe.contains('evil-attacker'), 'Malicious host must not be reflected: ' + safe);
|
|
165
|
+
System.assert(!safe.startsWith('http'), 'Result must not be an absolute URL: ' + safe);
|
|
166
|
+
System.assert(!safe.startsWith('//'), 'Result must not be protocol-relative: ' + safe);
|
|
167
|
+
System.assert(!safe.contains('\\'), 'Result must not contain a backslash: ' + safe);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Builds a GET RestContext with the given startURL param (null => param omitted). */
|
|
171
|
+
private static void setRequest(String startUrlValue) {
|
|
172
|
+
RestRequest req = new RestRequest();
|
|
173
|
+
req.requestURI = '/services/apexrest/auth/social-login-config';
|
|
174
|
+
req.httpMethod = 'GET';
|
|
175
|
+
if (startUrlValue != null) {
|
|
176
|
+
req.params.put('startURL', startUrlValue);
|
|
177
|
+
}
|
|
178
|
+
RestContext.request = req;
|
|
179
|
+
RestContext.response = new RestResponse();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"@angular/router": "^21.2.0",
|
|
29
29
|
"@ng-icons/core": "^32.2.0",
|
|
30
30
|
"@ng-icons/lucide": "^32.2.0",
|
|
31
|
-
"@salesforce/angular-plugin-ui-bundle": "
|
|
32
|
-
"@salesforce/platform-sdk": "
|
|
31
|
+
"@salesforce/angular-plugin-ui-bundle": "12.8.0-canary.1019.37.1.g1f7b157f",
|
|
32
|
+
"@salesforce/platform-sdk": "12.8.0-canary.1019.37.1.g1f7b157f",
|
|
33
33
|
"@spartan-ng/brain": "^1.3.1",
|
|
34
34
|
"class-variance-authority": "^0.7.1",
|
|
35
35
|
"clsx": "^2.1.1",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"typescript": "~5.9.2",
|
|
62
62
|
"typescript-eslint": "^8.0.0",
|
|
63
63
|
"vitest": "^4.1.11",
|
|
64
|
-
"@salesforce/graphiti": "
|
|
64
|
+
"@salesforce/graphiti": "12.8.0-canary.1019.37.1.g1f7b157f"
|
|
65
65
|
},
|
|
66
66
|
"overrides": {
|
|
67
67
|
"undici": "^7.28.0"
|
|
@@ -12,13 +12,16 @@ import {
|
|
|
12
12
|
lucideChevronDown,
|
|
13
13
|
lucideChevronRight,
|
|
14
14
|
lucideCircleAlert,
|
|
15
|
+
lucideFile,
|
|
15
16
|
lucideFileQuestion,
|
|
16
17
|
lucideGlobe,
|
|
18
|
+
lucideImage,
|
|
17
19
|
lucideListFilter,
|
|
18
20
|
lucideLogIn,
|
|
19
21
|
lucideLogOut,
|
|
20
22
|
lucideSearch,
|
|
21
23
|
lucideSearchX,
|
|
24
|
+
lucideUpload,
|
|
22
25
|
lucideUser,
|
|
23
26
|
lucideUserPen,
|
|
24
27
|
lucideUserPlus,
|
|
@@ -43,6 +46,9 @@ export const APP_ICONS = {
|
|
|
43
46
|
notFound: lucideFileQuestion,
|
|
44
47
|
globe: lucideGlobe,
|
|
45
48
|
check: lucideCheck,
|
|
49
|
+
image: lucideImage,
|
|
50
|
+
upload: lucideUpload,
|
|
51
|
+
file: lucideFile,
|
|
46
52
|
} as const;
|
|
47
53
|
|
|
48
54
|
export type AppIconName = keyof typeof APP_ICONS;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
@if (hasProviders()) {
|
|
2
|
+
<div class="mt-6">
|
|
3
|
+
@if (showHeader()) {
|
|
4
|
+
<div class="relative">
|
|
5
|
+
<div class="absolute inset-0 flex items-center">
|
|
6
|
+
<span class="w-full border-t"></span>
|
|
7
|
+
</div>
|
|
8
|
+
<div class="relative flex justify-center text-xs uppercase">
|
|
9
|
+
<span class="bg-background px-2 text-muted-foreground">{{ headerText() }}</span>
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
<div class="mt-4 grid gap-2">
|
|
15
|
+
@for (provider of authProviders(); track provider.developerName) {
|
|
16
|
+
<button
|
|
17
|
+
type="button"
|
|
18
|
+
(click)="redirectTo(provider.ssoUrl)"
|
|
19
|
+
[attr.aria-label]="'Sign in with ' + provider.friendlyName"
|
|
20
|
+
class="inline-flex w-full items-center justify-center gap-2 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
21
|
+
>
|
|
22
|
+
@if (provider.iconUrl) {
|
|
23
|
+
<img [src]="provider.iconUrl" alt="" class="h-4 w-4" aria-hidden="true" />
|
|
24
|
+
}
|
|
25
|
+
<span>{{ provider.friendlyName }}</span>
|
|
26
|
+
</button>
|
|
27
|
+
} @for (provider of samlProviders(); track provider.id) {
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
(click)="redirectTo(provider.ssoUrl)"
|
|
31
|
+
[attr.aria-label]="'Sign in with ' + provider.masterLabel"
|
|
32
|
+
class="inline-flex w-full items-center justify-center gap-2 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
33
|
+
>
|
|
34
|
+
<span>{{ provider.masterLabel }}</span>
|
|
35
|
+
</button>
|
|
36
|
+
}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026, Salesforce, Inc.,
|
|
3
|
+
* All rights reserved.
|
|
4
|
+
* For full license text, see the LICENSE.txt file
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
ChangeDetectionStrategy,
|
|
8
|
+
Component,
|
|
9
|
+
type OnInit,
|
|
10
|
+
computed,
|
|
11
|
+
inject,
|
|
12
|
+
input,
|
|
13
|
+
signal,
|
|
14
|
+
} from "@angular/core";
|
|
15
|
+
import { ActivatedRoute } from "@angular/router";
|
|
16
|
+
import { DataClient } from "../../../api/data-client.service";
|
|
17
|
+
import { getStartUrl } from "../utils/auth-helpers";
|
|
18
|
+
|
|
19
|
+
interface AuthProviderInfo {
|
|
20
|
+
developerName: string;
|
|
21
|
+
friendlyName: string;
|
|
22
|
+
iconUrl: string | null;
|
|
23
|
+
ssoUrl: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SamlProviderInfo {
|
|
27
|
+
id: string;
|
|
28
|
+
masterLabel: string;
|
|
29
|
+
ssoUrl: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SocialLoginConfigResponse {
|
|
33
|
+
authProviders: AuthProviderInfo[];
|
|
34
|
+
samlProviders: SamlProviderInfo[];
|
|
35
|
+
autoRedirectUrl: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Social Login (IDP) component — Angular port of the React `SocialLogin`.
|
|
40
|
+
*
|
|
41
|
+
* Replicates the OOTB community_login/socialLogin LWC behavior which does not
|
|
42
|
+
* render on Site Container sites due to a platform restriction (isSiteContainer
|
|
43
|
+
* check in node-loginsettings.ftl).
|
|
44
|
+
*
|
|
45
|
+
* Behavior:
|
|
46
|
+
* - On init, fetches configured Auth Providers + SAML Providers
|
|
47
|
+
* - If only one IDP is configured and username/password is disabled, auto-redirects
|
|
48
|
+
* - Renders a button per provider with icon (Auth Providers) or without (SAML)
|
|
49
|
+
* - On click, redirects to the SSO URL to initiate the OAuth/SAML flow
|
|
50
|
+
*
|
|
51
|
+
* Backend: UIBundleSocialLoginConfig.cls (Apex REST endpoint), copied verbatim
|
|
52
|
+
* from the React feature. Uses the Auth.AuthConfiguration API.
|
|
53
|
+
*/
|
|
54
|
+
@Component({
|
|
55
|
+
selector: "app-social-login",
|
|
56
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
57
|
+
templateUrl: "./social-login.html",
|
|
58
|
+
})
|
|
59
|
+
export class SocialLoginComponent implements OnInit {
|
|
60
|
+
private readonly route = inject(ActivatedRoute);
|
|
61
|
+
private readonly dataClient = inject(DataClient);
|
|
62
|
+
|
|
63
|
+
/** Whether to show the header text above the buttons. Default: true */
|
|
64
|
+
readonly showHeader = input(true);
|
|
65
|
+
|
|
66
|
+
/** Header text. Default: "Or log in using:" */
|
|
67
|
+
readonly headerText = input("Or log in using:");
|
|
68
|
+
|
|
69
|
+
protected readonly authProviders = signal<AuthProviderInfo[]>([]);
|
|
70
|
+
protected readonly samlProviders = signal<SamlProviderInfo[]>([]);
|
|
71
|
+
protected readonly hasProviders = computed(
|
|
72
|
+
() => this.authProviders().length > 0 || this.samlProviders().length > 0,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
async ngOnInit(): Promise<void> {
|
|
76
|
+
const startUrl = getStartUrl(this.route.snapshot.queryParamMap);
|
|
77
|
+
const config = await this.fetchConfig(startUrl);
|
|
78
|
+
if (!config) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (config.autoRedirectUrl) {
|
|
82
|
+
window.location.assign(config.autoRedirectUrl);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.authProviders.set(config.authProviders ?? []);
|
|
86
|
+
this.samlProviders.set(config.samlProviders ?? []);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
protected redirectTo(ssoUrl: string): void {
|
|
90
|
+
window.location.assign(ssoUrl);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async fetchConfig(startUrl: string): Promise<SocialLoginConfigResponse | null> {
|
|
94
|
+
try {
|
|
95
|
+
const response = await this.dataClient.fetch(
|
|
96
|
+
`/services/apexrest/auth/social-login-config?startURL=${encodeURIComponent(startUrl)}`,
|
|
97
|
+
{ method: "GET", headers: { Accept: "application/json" } },
|
|
98
|
+
);
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return (await response.json()) as SocialLoginConfigResponse;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -8,6 +8,7 @@ import { FormBuilder } from "@angular/forms";
|
|
|
8
8
|
import { ActivatedRoute, Router, RouterLink } from "@angular/router";
|
|
9
9
|
import { DataClient } from "../../api/data-client.service";
|
|
10
10
|
import { AuthFormComponent } from "../../features/authentication/auth-form/auth-form";
|
|
11
|
+
import { SocialLoginComponent } from "../../features/authentication/social-login/social-login";
|
|
11
12
|
import { ReactiveFormPageBase } from "../../features/authentication/reactive-form-page.base";
|
|
12
13
|
import { FieldComponent } from "../../components/ui/field/field";
|
|
13
14
|
import { InputComponent } from "../../components/ui/input/input";
|
|
@@ -32,7 +33,7 @@ import {
|
|
|
32
33
|
@Component({
|
|
33
34
|
selector: "app-login-page",
|
|
34
35
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
35
|
-
imports: [RouterLink, AuthFormComponent, FieldComponent, InputComponent],
|
|
36
|
+
imports: [RouterLink, AuthFormComponent, FieldComponent, InputComponent, SocialLoginComponent],
|
|
36
37
|
templateUrl: "./login-page.html",
|
|
37
38
|
})
|
|
38
39
|
export class LoginPageComponent extends ReactiveFormPageBase {
|
package/dist/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/ui-bundle-template-base-sfdx-project",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.8.0-canary.1019.37.1.g1f7b157f",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@salesforce/ui-bundle-template-base-sfdx-project",
|
|
9
|
-
"version": "12.
|
|
9
|
+
"version": "12.8.0-canary.1019.37.1.g1f7b157f",
|
|
10
10
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"fast-xml-parser": "^5.9.3",
|
package/dist/package.json
CHANGED