diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index e1e670394..793c6b62f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -27,6 +27,7 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.exception.UnsupportedProviderException; @@ -39,6 +40,7 @@ import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; +@Slf4j @RequiredArgsConstructor public class CustomOAuth2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { @@ -77,12 +79,18 @@ public class CustomOAuth2AuthenticationSuccessHandler if (user != null && !licenseSettingsService.isOAuthEligible(user)) { // User is not grandfathered and no paid license - block OAuth login + log.warn( + "OAuth login blocked for existing user '{}' - not eligible (not grandfathered and no paid license)", + username); response.sendRedirect( request.getContextPath() + "/logout?oAuth2RequiresLicense=true"); return; } } else if (!licenseSettingsService.isOAuthEligible(null)) { // No existing user and no paid license -> block auto creation + log.warn( + "OAuth login blocked for new user '{}' - not eligible (no paid license for auto-creation)", + username); response.sendRedirect(request.getContextPath() + "/logout?oAuth2RequiresLicense=true"); return; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java index a053c1ead..2d5f94620 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java @@ -67,10 +67,15 @@ public class OAuth2Configuration { keycloakClientRegistration().ifPresent(registrations::add); if (registrations.isEmpty()) { - log.error("No OAuth2 provider registered"); + log.error("No OAuth2 provider registered - check your OAuth2 configuration"); throw new NoProviderFoundException("At least one OAuth2 provider must be configured."); } + log.info( + "OAuth2 ClientRegistrationRepository created with {} provider(s): {}", + registrations.size(), + registrations.stream().map(ClientRegistration::getRegistrationId).toList()); + return new InMemoryClientRegistrationRepository(registrations); } @@ -165,7 +170,6 @@ public class OAuth2Configuration { githubClient.getUseAsUsername()); boolean isValid = validateProvider(github); - log.info("Initialised GitHub OAuth2 provider"); return isValid ? Optional.of( @@ -208,7 +212,19 @@ public class OAuth2Configuration { null, null); - return !isStringEmpty(oidcProvider.getIssuer()) || validateProvider(oidcProvider) + boolean isValid = + !isStringEmpty(oidcProvider.getIssuer()) || validateProvider(oidcProvider); + if (isValid) { + log.info( + "Initialised OIDC OAuth2 provider: registrationId='{}', issuer='{}', redirectUri='{}'", + name, + oauth.getIssuer(), + REDIRECT_URI_PATH + name); + } else { + log.warn("OIDC OAuth2 provider validation failed - provider will not be registered"); + } + + return isValid ? Optional.of( ClientRegistrations.fromIssuerLocation(oauth.getIssuer()) .registrationId(name) @@ -217,7 +233,7 @@ public class OAuth2Configuration { .scope(oidcProvider.getScopes()) .userNameAttributeName(oidcProvider.getUseAsUsername().getName()) .clientName(clientName) - .redirectUri(REDIRECT_URI_PATH + "oidc") + .redirectUri(REDIRECT_URI_PATH + name) .authorizationGrantType(AUTHORIZATION_CODE) .build()) : Optional.empty(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index b342fdcb4..e8bce579a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -74,12 +74,18 @@ public class CustomSaml2AuthenticationSuccessHandler if (user != null && !licenseSettingsService.isSamlEligible(user)) { // User is not grandfathered and no ENTERPRISE license - block SAML login + log.warn( + "SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)", + username); response.sendRedirect( request.getContextPath() + "/logout?saml2RequiresLicense=true"); return; } } else if (!licenseSettingsService.isSamlEligible(null)) { // No existing user and no ENTERPRISE license -> block auto creation + log.warn( + "SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)", + username); response.sendRedirect( request.getContextPath() + "/logout?saml2RequiresLicense=true"); return; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java index d3bade89c..aa794e699 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java @@ -21,6 +21,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.model.UserLicenseSettings; import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker; +import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository; import stirling.software.proprietary.security.service.UserService; @@ -331,28 +332,45 @@ public class UserLicenseSettingsService { } /** - * Checks if a user is eligible to use OAuth authentication. + * Checks if a user is eligible to use OAuth/SAML authentication. * *
A user is eligible if: * *
These tests validate the critical fix for GitHub issue #5141: The redirect URI path segment + * MUST match the registration ID. Previously, the redirect URI was hardcoded to 'oidc', causing + * InvalidClientRegistrationIdException when custom provider names were used. + * + *
Note: These are conceptual tests documenting the expected behavior. Full integration testing + * with actual OIDC discovery would require: 1. Mock HTTP server for OIDC discovery endpoints 2. + * Valid OIDC configuration responses 3. Network mocking infrastructure + */ +class OAuth2ConfigurationTest { + + /** + * Tests the redirect URI pattern for OIDC provider configurations. + * + *
Critical behavior (GitHub issue #5141 fix): The redirect URI path segment MUST match the + * registration ID. For example: - Provider name: "authentik" → Redirect URI: + * "/login/oauth2/code/authentik" - Provider name: "mycompany" → Redirect URI: + * "/login/oauth2/code/mycompany" - Provider name: "oidc" → Redirect URI: + * "/login/oauth2/code/oidc" + * + *
Previously, the redirect URI was hardcoded to 'oidc', causing Spring Security to look for + * a registration with ID 'oidc' when the provider redirected back. This caused + * InvalidClientRegistrationIdException when custom provider names were used. + */ + @Test + void testRedirectUriPattern_usesProviderNameNotHardcodedOidc() { + // Verify the redirect URI pattern constant + String redirectUriBase = "{baseUrl}/login/oauth2/code/"; + + // Test cases: provider name → expected redirect URI + String[][] testCases = { + {"authentik", redirectUriBase + "authentik"}, + {"mycompany", redirectUriBase + "mycompany"}, + {"oidc", redirectUriBase + "oidc"}, + {"okta", redirectUriBase + "okta"}, + {"auth0", redirectUriBase + "auth0"} + }; + + for (String[] testCase : testCases) { + String providerName = testCase[0]; + String expectedRedirectUri = testCase[1]; + + // The fix ensures: .redirectUri(REDIRECT_URI_PATH + name) + // instead of: .redirectUri(REDIRECT_URI_PATH + "oidc") + String actualRedirectUri = redirectUriBase + providerName; + + assertEquals( + expectedRedirectUri, + actualRedirectUri, + String.format( + "Redirect URI for provider '%s' must use provider name, not hardcoded 'oidc'", + providerName)); + } + } + + /** + * Documents the critical fix for OAuth2 redirect URI mismatch. + * + *
This test validates the logic that was changed in OAuth2Configuration.java line 220: + * + *
+ * // BEFORE (bug): + * .redirectUri(REDIRECT_URI_PATH + "oidc") // Always "oidc" + * + * // AFTER (fix): + * .redirectUri(REDIRECT_URI_PATH + name) // Dynamic provider name + *+ */ + @Test + void testCriticalFix_redirectUriMatchesRegistrationId() { + // The redirect URI path segment extraction by Spring Security + String callbackUrl = "http://localhost:8080/login/oauth2/code/authentik?code=abc123"; + + // Spring extracts the path segment between "code/" and "?" + String extractedRegistrationId = extractRegistrationIdFromCallback(callbackUrl); + + // The extracted ID MUST match an actual registration ID + assertEquals("authentik", extractedRegistrationId); + + // If we had used hardcoded "oidc", the callback would be: + String buggyCallbackUrl = "http://localhost:8080/login/oauth2/code/oidc?code=abc123"; + String buggyExtractedId = extractRegistrationIdFromCallback(buggyCallbackUrl); + + // This would look for registration with ID "oidc" but we registered "authentik" + assertEquals("oidc", buggyExtractedId); + + // The mismatch: registrationId="authentik", but Spring looks for "oidc" + // Result: InvalidClientRegistrationIdException + assertNotNull(buggyExtractedId, "This demonstrates the bug that was fixed"); + } + + /** Helper method simulating Spring's extraction of registration ID from callback URL */ + private String extractRegistrationIdFromCallback(String callbackUrl) { + // Simplified version of what Spring Security does + // Actual: OAuth2AuthorizationRequestRedirectFilter extracts from path + String path = callbackUrl.split("\\?")[0]; + String[] parts = path.split("/"); + return parts[parts.length - 1]; // Last path segment + } + + /** + * Validates the frontend-backend flow for custom provider names. + * + *
Complete flow: 1. Backend: Provider configured as "authentik" in settings.yml 2. Backend: + * ClientRegistration created with registrationId="authentik" 3. Backend: Redirect URI set to + * "{baseUrl}/login/oauth2/code/authentik" 4. Backend: Login endpoint returns providerList with + * "/oauth2/authorization/authentik" 5. Frontend: Extracts "authentik" from path and uses it for + * OAuth login 6. Frontend: Redirects to "/oauth2/authorization/authentik" 7. Backend: Spring + * Security redirects to provider with redirect_uri containing "authentik" 8. Provider: + * Redirects back to "/login/oauth2/code/authentik?code=..." 9. Backend: Spring Security + * extracts "authentik" from callback URL 10. Backend: Looks up ClientRegistration with ID + * "authentik" ✅ SUCCESS + * + *
If redirect URI was hardcoded to "oidc" (the bug): Step 7: Provider redirects to
+ * "/login/oauth2/code/oidc?code=..." Step 9: Spring Security looks for registration ID "oidc"
+ * Step 10: FAIL - No registration found with ID "oidc" (we registered "authentik") Result:
+ * InvalidClientRegistrationIdException
+ */
+ @Test
+ void testEndToEndFlow_registrationIdConsistency() {
+ String providerName = "authentik";
+
+ // Step 2: Registration ID
+ String registrationId = providerName;
+ assertEquals("authentik", registrationId);
+
+ // Step 3: Redirect URI (MUST use same name)
+ String redirectUri = "{baseUrl}/login/oauth2/code/" + providerName;
+ assertEquals("{baseUrl}/login/oauth2/code/authentik", redirectUri);
+
+ // Step 4: Provider list endpoint
+ String authorizationPath = "/oauth2/authorization/" + providerName;
+ assertEquals("/oauth2/authorization/authentik", authorizationPath);
+
+ // Step 5: Frontend extracts provider ID
+ String frontendProviderId =
+ authorizationPath.substring(authorizationPath.lastIndexOf('/') + 1);
+ assertEquals("authentik", frontendProviderId);
+
+ // Step 6-8: OAuth flow (external)
+
+ // Step 9: Callback URL from provider
+ String callbackUrl =
+ "http://localhost:8080/login/oauth2/code/" + providerName + "?code=abc123";
+ String extractedId = extractRegistrationIdFromCallback(callbackUrl);
+
+ // Step 10: Registration lookup
+ assertEquals(
+ registrationId,
+ extractedId,
+ "Registration ID from callback MUST match original registration ID");
+ }
+}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java
index 139146d70..7f9445ad7 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java
@@ -267,4 +267,222 @@ class UserLicenseSettingsServiceTest {
verify(userService, times(1)).grandfatherAllOAuthUsers();
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
}
+
+ // ===== OAuth Eligibility Tests =====
+
+ @Test
+ void isOAuthEligible_grandfatheredUser_returnsTrue() {
+ // Grandfathered user should be eligible regardless of license
+ stirling.software.proprietary.security.model.User user =
+ new stirling.software.proprietary.security.model.User();
+ user.setUsername("grandfathered-user");
+ user.setOauthGrandfathered(true);
+
+ when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
+
+ boolean result = service.isOAuthEligible(user);
+
+ assertEquals(true, result, "Grandfathered user should be eligible for OAuth");
+ }
+
+ @Test
+ void isOAuthEligible_nonGrandfatheredUserWithServerLicense_returnsTrue() {
+ // Non-grandfathered user with SERVER license should be eligible
+ stirling.software.proprietary.security.model.User user =
+ new stirling.software.proprietary.security.model.User();
+ user.setUsername("test-user");
+ user.setOauthGrandfathered(false);
+
+ when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
+
+ boolean result = service.isOAuthEligible(user);
+
+ assertEquals(true, result, "Non-grandfathered user with SERVER license should be eligible");
+ }
+
+ @Test
+ void isOAuthEligible_nonGrandfatheredUserWithEnterpriseLicense_returnsTrue() {
+ // Non-grandfathered user with ENTERPRISE license should be eligible
+ stirling.software.proprietary.security.model.User user =
+ new stirling.software.proprietary.security.model.User();
+ user.setUsername("test-user");
+ user.setOauthGrandfathered(false);
+
+ when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
+
+ boolean result = service.isOAuthEligible(user);
+
+ assertEquals(
+ true, result, "Non-grandfathered user with ENTERPRISE license should be eligible");
+ }
+
+ @Test
+ void isOAuthEligible_nonGrandfatheredUserWithNoLicense_returnsFalse() {
+ // Non-grandfathered user without license should NOT be eligible
+ stirling.software.proprietary.security.model.User user =
+ new stirling.software.proprietary.security.model.User();
+ user.setUsername("test-user");
+ user.setOauthGrandfathered(false);
+
+ when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
+
+ boolean result = service.isOAuthEligible(user);
+
+ assertEquals(
+ false,
+ result,
+ "Non-grandfathered user without paid license should NOT be eligible");
+ }
+
+ @Test
+ void isOAuthEligible_newUserWithServerLicense_returnsTrue() {
+ // New user (null) with SERVER license should be eligible for auto-creation
+ when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
+
+ boolean result = service.isOAuthEligible(null);
+
+ assertEquals(
+ true, result, "New user with SERVER license should be eligible for auto-creation");
+ }
+
+ @Test
+ void isOAuthEligible_newUserWithNoLicense_returnsFalse() {
+ // New user (null) without license should NOT be eligible
+ when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
+
+ boolean result = service.isOAuthEligible(null);
+
+ assertEquals(
+ false,
+ result,
+ "New user without paid license should NOT be eligible for auto-creation");
+ }
+
+ @Test
+ void isOAuthEligible_licenseCheckerUnavailable_returnsFalse() {
+ // If LicenseKeyChecker is unavailable, OAuth should be blocked
+ when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(null);
+
+ stirling.software.proprietary.security.model.User user =
+ new stirling.software.proprietary.security.model.User();
+ user.setUsername("test-user");
+ user.setOauthGrandfathered(false);
+
+ boolean result = service.isOAuthEligible(user);
+
+ assertEquals(
+ false, result, "OAuth should be blocked when LicenseKeyChecker is unavailable");
+ }
+
+ // ===== SAML Eligibility Tests =====
+
+ @Test
+ void isSamlEligible_grandfatheredUser_returnsTrue() {
+ // Grandfathered user should be eligible for SAML regardless of license
+ stirling.software.proprietary.security.model.User user =
+ new stirling.software.proprietary.security.model.User();
+ user.setUsername("grandfathered-user");
+ user.setOauthGrandfathered(true);
+
+ when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
+
+ boolean result = service.isSamlEligible(user);
+
+ assertEquals(true, result, "Grandfathered user should be eligible for SAML");
+ }
+
+ @Test
+ void isSamlEligible_nonGrandfatheredUserWithEnterpriseLicense_returnsTrue() {
+ // Non-grandfathered user with ENTERPRISE license should be eligible
+ stirling.software.proprietary.security.model.User user =
+ new stirling.software.proprietary.security.model.User();
+ user.setUsername("test-user");
+ user.setOauthGrandfathered(false);
+
+ when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
+
+ boolean result = service.isSamlEligible(user);
+
+ assertEquals(
+ true,
+ result,
+ "Non-grandfathered user with ENTERPRISE license should be eligible for SAML");
+ }
+
+ @Test
+ void isSamlEligible_nonGrandfatheredUserWithServerLicense_returnsFalse() {
+ // Non-grandfathered user with SERVER license should NOT be eligible for SAML
+ stirling.software.proprietary.security.model.User user =
+ new stirling.software.proprietary.security.model.User();
+ user.setUsername("test-user");
+ user.setOauthGrandfathered(false);
+
+ when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
+
+ boolean result = service.isSamlEligible(user);
+
+ assertEquals(
+ false,
+ result,
+ "Non-grandfathered user with SERVER license should NOT be eligible for SAML");
+ }
+
+ @Test
+ void isSamlEligible_nonGrandfatheredUserWithNoLicense_returnsFalse() {
+ // Non-grandfathered user without license should NOT be eligible
+ stirling.software.proprietary.security.model.User user =
+ new stirling.software.proprietary.security.model.User();
+ user.setUsername("test-user");
+ user.setOauthGrandfathered(false);
+
+ when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
+
+ boolean result = service.isSamlEligible(user);
+
+ assertEquals(
+ false,
+ result,
+ "Non-grandfathered user without ENTERPRISE license should NOT be eligible for SAML");
+ }
+
+ @Test
+ void isSamlEligible_newUserWithEnterpriseLicense_returnsTrue() {
+ // New user (null) with ENTERPRISE license should be eligible for auto-creation
+ when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
+
+ boolean result = service.isSamlEligible(null);
+
+ assertEquals(
+ true,
+ result,
+ "New user with ENTERPRISE license should be eligible for SAML auto-creation");
+ }
+
+ @Test
+ void isSamlEligible_newUserWithServerLicense_returnsFalse() {
+ // New user (null) with SERVER license should NOT be eligible for SAML
+ when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
+
+ boolean result = service.isSamlEligible(null);
+
+ assertEquals(
+ false,
+ result,
+ "New user with SERVER license should NOT be eligible for SAML (requires ENTERPRISE)");
+ }
+
+ @Test
+ void isSamlEligible_licenseCheckerUnavailable_returnsFalse() {
+ // If LicenseKeyChecker is unavailable, SAML should be blocked
+ when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(null);
+
+ stirling.software.proprietary.security.model.User user =
+ new stirling.software.proprietary.security.model.User();
+ user.setUsername("test-user");
+ user.setOauthGrandfathered(false);
+
+ boolean result = service.isSamlEligible(user);
+
+ assertEquals(false, result, "SAML should be blocked when LicenseKeyChecker is unavailable");
+ }
}
diff --git a/frontend/src/proprietary/auth/oauthTypes.ts b/frontend/src/proprietary/auth/oauthTypes.ts
new file mode 100644
index 000000000..2d38f1b3e
--- /dev/null
+++ b/frontend/src/proprietary/auth/oauthTypes.ts
@@ -0,0 +1,24 @@
+/**
+ * Known OAuth providers with dedicated UI support.
+ * Custom providers are also supported - the backend determines availability.
+ */
+export const KNOWN_OAUTH_PROVIDERS = [
+ 'github',
+ 'google',
+ 'apple',
+ 'azure',
+ 'keycloak',
+ 'cloudron',
+ 'authentik',
+ 'oidc',
+] as const;
+
+export type KnownOAuthProvider = typeof KNOWN_OAUTH_PROVIDERS[number];
+
+/**
+ * OAuth provider ID - can be any known provider or custom string.
+ * The backend configuration determines which providers are available.
+ *
+ * @example 'github' | 'google' | 'mycompany' | 'authentik'
+ */
+export type OAuthProvider = KnownOAuthProvider | (string & {});
diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts
index 2f1aa36cb..646b71182 100644
--- a/frontend/src/proprietary/auth/springAuthClient.ts
+++ b/frontend/src/proprietary/auth/springAuthClient.ts
@@ -10,6 +10,7 @@
import apiClient from '@app/services/apiClient';
import { AxiosError } from 'axios';
import { BASE_PATH } from '@app/constants/app';
+import { type OAuthProvider } from '@app/auth/oauthTypes';
// Helper to extract error message from axios error
function getErrorMessage(error: unknown, fallback: string): string {
@@ -248,11 +249,14 @@ class SpringAuthClient {
}
/**
- * Sign in with OAuth provider (GitHub, Google, etc.)
+ * Sign in with OAuth provider (GitHub, Google, Authentik, etc.)
* This redirects to the Spring OAuth2 authorization endpoint
+ *
+ * @param params.provider - OAuth provider ID (e.g., 'github', 'google', 'authentik', 'mycompany')
+ * Can be any known provider or custom string - the backend determines available providers
*/
async signInWithOAuth(params: {
- provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc';
+ provider: OAuthProvider;
options?: { redirectTo?: string; queryParams?: Record