diff --git a/build/update-config.sh b/build/update-config.sh index 1bc23e722a..32fd68d1f9 100644 --- a/build/update-config.sh +++ b/build/update-config.sh @@ -11,7 +11,7 @@ for part in "${oauth_parts[@]}"; do value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" case "$key" in - FacebookId|GitHubId|GoogleId|IntercomId|MicrosoftId|SlackId) + GitHubId|GoogleId|IntercomId|MicrosoftId|SlackId) oauth_ids[$key]="$value" ;; esac done @@ -43,7 +43,6 @@ mkdir -p _app write_setting PUBLIC_EXCEPTIONLESS_CLIENT_SETUP_SHOW_SERVER_URL "${EX_ClientSetupShowServerUrl:-true}" write_setting PUBLIC_EXCEPTIONLESS_SERVER_URL "${EX_ExceptionlessServerUrl:-}" write_setting PUBLIC_STRIPE_PUBLISHABLE_KEY "${EX_StripePublishableApiKey:-}" - write_setting PUBLIC_FACEBOOK_APPID "${oauth_ids[FacebookId]:-}" write_setting PUBLIC_GITHUB_APPID "${oauth_ids[GitHubId]:-}" write_setting PUBLIC_GOOGLE_APPID "${oauth_ids[GoogleId]:-}" write_setting PUBLIC_MICROSOFT_APPID "${oauth_ids[MicrosoftId]:-}" diff --git a/src/Exceptionless.AppHost/Program.cs b/src/Exceptionless.AppHost/Program.cs index a7b0bcc998..983e877ca9 100644 --- a/src/Exceptionless.AppHost/Program.cs +++ b/src/Exceptionless.AppHost/Program.cs @@ -216,7 +216,7 @@ .WithEnvironment("API_HTTPS", worktreePorts.ApiHttpsUrl); } - // The e2e workflow runs npm ci before starting the AppHost, so a second install would only delay the app. + // The e2e workflow runs npm ci before starting the AppHost, so it skips Aspire's second install. if (ciE2E) app.WithNpm(install: false); diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index 92c2ad4c43..0d45dc5823 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -374,8 +374,6 @@ private static string GetEnabledIntegrations(AppOptions options) integrations.Add("Google OAuth"); if (!String.IsNullOrWhiteSpace(options.AuthOptions.MicrosoftId)) integrations.Add("Microsoft OAuth"); - if (!String.IsNullOrWhiteSpace(options.AuthOptions.FacebookId)) - integrations.Add("Facebook OAuth"); if (!String.IsNullOrWhiteSpace(options.AuthOptions.GitHubId)) integrations.Add("GitHub OAuth"); if (options.AuthOptions.EnableActiveDirectoryAuth) diff --git a/src/Exceptionless.Core/Configuration/AuthOptions.cs b/src/Exceptionless.Core/Configuration/AuthOptions.cs index f394c32f99..b7bb6ce807 100644 --- a/src/Exceptionless.Core/Configuration/AuthOptions.cs +++ b/src/Exceptionless.Core/Configuration/AuthOptions.cs @@ -13,10 +13,6 @@ public class AuthOptions public string? MicrosoftSecret { get; internal set; } - public string? FacebookId { get; internal set; } - - public string? FacebookSecret { get; internal set; } - public string? GitHubId { get; internal set; } public string? GitHubSecret { get; internal set; } @@ -41,8 +37,6 @@ public static AuthOptions ReadFromConfiguration(IConfiguration config) options.GoogleSecret = oAuth.GetString(nameof(options.GoogleSecret)); options.MicrosoftId = oAuth.GetString(nameof(options.MicrosoftId)); options.MicrosoftSecret = oAuth.GetString(nameof(options.MicrosoftSecret)); - options.FacebookId = oAuth.GetString(nameof(options.FacebookId)); - options.FacebookSecret = oAuth.GetString(nameof(options.FacebookSecret)); options.GitHubId = oAuth.GetString(nameof(options.GitHubId)); options.GitHubSecret = oAuth.GetString(nameof(options.GitHubSecret)); diff --git a/src/Exceptionless.Core/Extensions/UserExtensions.cs b/src/Exceptionless.Core/Extensions/UserExtensions.cs index a04982adbc..c811e3be5b 100644 --- a/src/Exceptionless.Core/Extensions/UserExtensions.cs +++ b/src/Exceptionless.Core/Extensions/UserExtensions.cs @@ -79,13 +79,19 @@ public static void AddOAuthAccount(this User user, string providerName, string p public static bool RemoveOAuthAccount(this User user, string providerName, string providerUserId) { - if (user.OAuthAccounts.Count <= 1 && String.IsNullOrEmpty(user.Password)) - return false; - - var account = user.OAuthAccounts.FirstOrDefault(o => o.Provider == providerName.ToLowerInvariant() && o.ProviderUserId == providerUserId); + var account = user.OAuthAccounts.FirstOrDefault(o => + String.Equals(o.Provider, providerName, StringComparison.OrdinalIgnoreCase) + && String.Equals(o.ProviderUserId, providerUserId, StringComparison.Ordinal)); if (account is null) return true; + var activeOAuthAccounts = user.OAuthAccounts + .Where(o => !String.Equals(o.Provider, "facebook", StringComparison.OrdinalIgnoreCase) + && !String.Equals(o.Provider, "windowslive", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (String.IsNullOrEmpty(user.Password) && activeOAuthAccounts.Length <= 1 && activeOAuthAccounts.Contains(account)) + return false; + return user.OAuthAccounts.Remove(account); } } diff --git a/src/Exceptionless.Insulation/Security/SensitiveDataLogging.cs b/src/Exceptionless.Insulation/Security/SensitiveDataLogging.cs index aeb7bf6860..aa7f3df888 100644 --- a/src/Exceptionless.Insulation/Security/SensitiveDataLogging.cs +++ b/src/Exceptionless.Insulation/Security/SensitiveDataLogging.cs @@ -58,7 +58,6 @@ private sealed record SensitivePropertyRule( Redact(nameof(AppOptions.GoogleGeocodingApiKey)), Redact(nameof(AppOptions.MaxMindGeoIpKey)), Redact(nameof(AuthOptions.MicrosoftSecret)), - Redact(nameof(AuthOptions.FacebookSecret)), Redact(nameof(AuthOptions.GitHubSecret)), Redact(nameof(AuthOptions.GoogleSecret)), Sanitize( diff --git a/src/Exceptionless.Job/appsettings.Production.yml b/src/Exceptionless.Job/appsettings.Production.yml index b01a5221e1..c7cc61b1d8 100644 --- a/src/Exceptionless.Job/appsettings.Production.yml +++ b/src/Exceptionless.Job/appsettings.Production.yml @@ -7,7 +7,7 @@ ConnectionStrings: # Storage: '' # Email: 'smtps://user:password@domain.com:587' # LDAP: '' - OAuth: FacebookId=395178683904310;GitHubId=7ef1dd5bfbc4ccf7f5ef;GoogleId=809763155066-enkkdmt4ierc33q9cft9nf5d5c02h30q.apps.googleusercontent.com;MicrosoftId=000000004C137E8B;SlackId=34500115540.177239122322; + OAuth: GitHubId=7ef1dd5bfbc4ccf7f5ef;GoogleId=809763155066-enkkdmt4ierc33q9cft9nf5d5c02h30q.apps.googleusercontent.com;MicrosoftId=;SlackId=34500115540.177239122322; # Base url for the ui used to build links in emails and other places. BaseURL: https://be.exceptionless.io diff --git a/src/Exceptionless.Job/appsettings.Staging.yml b/src/Exceptionless.Job/appsettings.Staging.yml index 608f6972a1..fc1af1fa7a 100644 --- a/src/Exceptionless.Job/appsettings.Staging.yml +++ b/src/Exceptionless.Job/appsettings.Staging.yml @@ -6,7 +6,7 @@ ConnectionStrings: # MessageBus: provider=redis; # Queue: provider=redis; # Storage: provider=folder;path=.\storage= - OAuth: FacebookId=395178683904310;GitHubId=7ef1dd5bfbc4ccf7f5ef;GoogleId=809763155066-enkkdmt4ierc33q9cft9nf5d5c02h30q.apps.googleusercontent.com;MicrosoftId=000000004C137E8B;SlackId=34500115540.177239122322; + OAuth: GitHubId=7ef1dd5bfbc4ccf7f5ef;GoogleId=809763155066-enkkdmt4ierc33q9cft9nf5d5c02h30q.apps.googleusercontent.com;MicrosoftId=;SlackId=34500115540.177239122322; # Base url for the ui used to build links in emails and other places. BaseURL: https://dev-app.exceptionless.io diff --git a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs index 7b1fc2b41a..a9db9da0cc 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs @@ -133,27 +133,9 @@ headers api_key input box. } }); - group.MapPost("facebook", async (IMediator mediator, IMediatorResultMapper resultMapper, HttpContext httpContext, [FromBody] ExternalAuthInfo value) => + group.MapPost("microsoft", async (IMediator mediator, IMediatorResultMapper resultMapper, HttpContext httpContext, [FromBody] ExternalAuthInfo value) => { - return (await mediator.InvokeAsync>(new AuthMessages.FacebookLogin(value, httpContext))).ToHttpResult(resultMapper); - }) - .AllowAnonymous() - .Accepts("application/json", "application/*+json") - .Produces() - .ProducesProblem(StatusCodes.Status403Forbidden) - .ProducesProblem(StatusCodes.Status422UnprocessableEntity) - .WithSummary("Sign in with Facebook") - .WithMetadata(new EndpointDocumentation { - ResponseDescriptions = new() { - ["200"] = "User Authentication Token", - ["403"] = "Account Creation is currently disabled", - ["422"] = "Validation error", - } - }); - - group.MapPost("live", async (IMediator mediator, IMediatorResultMapper resultMapper, HttpContext httpContext, [FromBody] ExternalAuthInfo value) => - { - return (await mediator.InvokeAsync>(new AuthMessages.LiveLogin(value, httpContext))).ToHttpResult(resultMapper); + return (await mediator.InvokeAsync>(new AuthMessages.MicrosoftLogin(value, httpContext))).ToHttpResult(resultMapper); }) .AllowAnonymous() .Accepts("application/json", "application/*+json") diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index fc37943515..afbc11b311 100644 --- a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -37,6 +37,8 @@ public class AuthHandler( TimeProvider timeProvider, ILogger logger) { + private const string LegacyMicrosoftOAuthProvider = "WindowsLive"; + private const string MicrosoftOAuthProvider = "Microsoft"; private readonly ScopedCacheClient _cache = new(cacheClient, "Auth"); private static bool _isFirstUserChecked; private static readonly TimeSpan IntercomJwtLifetime = TimeSpan.FromMinutes(60); @@ -272,16 +274,7 @@ public Task> Handle(GoogleLogin message) ); } - public Task> Handle(FacebookLogin message) - { - return ExternalLoginAsync(message.AuthInfo, message.Context, - authOptions.FacebookId, - authOptions.FacebookSecret, - oauthProviderClient.GetFacebookUserInfoAsync - ); - } - - public Task> Handle(LiveLogin message) + public Task> Handle(MicrosoftLogin message) { return ExternalLoginAsync(message.AuthInfo, message.Context, authOptions.MicrosoftId, @@ -302,19 +295,16 @@ public async Task> Handle(RemoveExternalLogin message) return Result.BadRequest("Invalid Provider Name or Provider User Id."); } - if (user.OAuthAccounts.Count <= 1 && String.IsNullOrEmpty(user.Password)) - { - logger.LogError("Remove external login failed for {EmailAddress}: You must set a local password before removing your external login", user.EmailAddress); - return Result.BadRequest("You must set a local password before removing your external login."); - } - try { - if (user.RemoveOAuthAccount(message.ProviderName, message.ProviderUserId.Value)) + if (!user.RemoveOAuthAccount(message.ProviderName, message.ProviderUserId.Value)) { - user.AuthenticationVersion = Core.Extensions.StringExtensions.GetNewToken(); - await userRepository.SaveAsync(user, o => o.Cache()); + logger.LogError("Remove external login failed for {EmailAddress}: You must set a local password before removing your external login", user.EmailAddress); + return Result.BadRequest("You must set a local password before removing your external login."); } + + user.AuthenticationVersion = Core.Extensions.StringExtensions.GetNewToken(); + await userRepository.SaveAsync(user, o => o.Cache()); } catch (Exception ex) { @@ -567,26 +557,35 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit } else { + if (RemoveLegacyMicrosoftOAuthAccounts(currentUser, userInfo.ProviderName)) + return await userRepository.SaveAsync(currentUser, o => o.Cache()); + return currentUser; } } currentUser.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email); + RemoveLegacyMicrosoftOAuthAccounts(currentUser, userInfo.ProviderName); return await userRepository.SaveAsync(currentUser, o => o.Cache()); } if (existingUser is not null) { + bool hasChanges = RemoveLegacyMicrosoftOAuthAccounts(existingUser, userInfo.ProviderName); if (!existingUser.IsEmailAddressVerified) { existingUser.MarkEmailAddressVerified(); - await userRepository.SaveAsync(existingUser, o => o.Cache()); + hasChanges = true; } + if (hasChanges) + await userRepository.SaveAsync(existingUser, o => o.Cache()); + return existingUser; } var user = !String.IsNullOrEmpty(userInfo.Email) ? await userRepository.GetByEmailAddressAsync(userInfo.Email) : null; + if (user is null) { if (!await IsAccountCreationEnabledAsync(inviteToken)) @@ -609,6 +608,20 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit return user; } + private static bool RemoveLegacyMicrosoftOAuthAccounts(User user, string providerName) + { + if (!String.Equals(providerName, MicrosoftOAuthProvider, StringComparison.OrdinalIgnoreCase)) + return false; + + var legacyAccounts = user.OAuthAccounts + .Where(account => String.Equals(account.Provider, LegacyMicrosoftOAuthProvider, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + foreach (var account in legacyAccounts) + user.OAuthAccounts.Remove(account); + + return legacyAccounts.Length > 0; + } + private async Task IsAccountCreationEnabledAsync(string? token) { if (authOptions.EnableAccountCreation) diff --git a/src/Exceptionless.Web/Api/Messages/AuthMessages.cs b/src/Exceptionless.Web/Api/Messages/AuthMessages.cs index 068710fd2d..134ee74ca7 100644 --- a/src/Exceptionless.Web/Api/Messages/AuthMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/AuthMessages.cs @@ -8,8 +8,7 @@ public record LogoutMessage(HttpContext Context); public record SignupMessage(Signup Model, HttpContext Context); public record GitHubLogin(ExternalAuthInfo AuthInfo, HttpContext Context); public record GoogleLogin(ExternalAuthInfo AuthInfo, HttpContext Context); -public record FacebookLogin(ExternalAuthInfo AuthInfo, HttpContext Context); -public record LiveLogin(ExternalAuthInfo AuthInfo, HttpContext Context); +public record MicrosoftLogin(ExternalAuthInfo AuthInfo, HttpContext Context); public record RemoveExternalLogin(string ProviderName, ValueFromBody ProviderUserId, HttpContext Context); public record ChangePassword(ChangePasswordModel Model, HttpContext Context); public record CheckEmailAddress(string Email, HttpContext Context); diff --git a/src/Exceptionless.Web/ClientApp/.env b/src/Exceptionless.Web/ClientApp/.env index c7364a03d3..90c666eb7a 100644 --- a/src/Exceptionless.Web/ClientApp/.env +++ b/src/Exceptionless.Web/ClientApp/.env @@ -3,7 +3,6 @@ PUBLIC_ENABLE_ACCOUNT_CREATION=true PUBLIC_EXCEPTIONLESS_API_KEY= PUBLIC_EXCEPTIONLESS_CLIENT_SETUP_SHOW_SERVER_URL=true PUBLIC_EXCEPTIONLESS_SERVER_URL= -PUBLIC_FACEBOOK_APPID= PUBLIC_GITHUB_APPID= PUBLIC_GOOGLE_APPID= PUBLIC_INTERCOM_APPID= diff --git a/src/Exceptionless.Web/ClientApp/src/env.ts b/src/Exceptionless.Web/ClientApp/src/env.ts index d6f55588bd..2904e08cc0 100644 --- a/src/Exceptionless.Web/ClientApp/src/env.ts +++ b/src/Exceptionless.Web/ClientApp/src/env.ts @@ -14,7 +14,6 @@ export const variables = defineEnvVars({ PUBLIC_EXCEPTIONLESS_CLIENT_SETUP_SHOW_SERVER_URL: { public: true }, PUBLIC_EXCEPTIONLESS_SERVER_URL: { description: 'Server URL shown in client setup instructions and used for UI telemetry.', public: true }, PUBLIC_EXCEPTIONLESS_TELEMETRY_SERVER_URL: { public: true, schema: optional }, - PUBLIC_FACEBOOK_APPID: { public: true }, PUBLIC_GITHUB_APPID: { public: true }, PUBLIC_GOOGLE_APPID: { public: true }, PUBLIC_INTERCOM_APPID: { public: true }, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts index 2d7aff7947..a98bce3c7a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts @@ -1,17 +1,12 @@ -import { - PUBLIC_ENABLE_ACCOUNT_CREATION, - PUBLIC_FACEBOOK_APPID, - PUBLIC_GITHUB_APPID, - PUBLIC_GOOGLE_APPID, - PUBLIC_MICROSOFT_APPID, - PUBLIC_SLACK_APPID -} from '$app/env/public'; +import { PUBLIC_ENABLE_ACCOUNT_CREATION, PUBLIC_GITHUB_APPID, PUBLIC_GOOGLE_APPID, PUBLIC_MICROSOFT_APPID, PUBLIC_SLACK_APPID } from '$app/env/public'; import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import { useFetchClient } from '@foundatiofx/fetchclient'; +import { toast } from 'svelte-sonner'; import { gotoRedirect } from '#features/navigation/redirect.ts'; import { getVisibleUrl } from '#features/navigation/visible-url.ts'; +import { getProblemMessage } from '#shared/validation.ts'; import type { TokenResult } from './models'; @@ -54,33 +49,17 @@ export interface OAuthResponseData { state: string; } -export type SupportedOAuthProviders = 'facebook' | 'github' | 'google' | 'live' | 'slack'; +export type SupportedOAuthProviders = 'github' | 'google' | 'microsoft' | 'slack'; export const enableAccountCreation = PUBLIC_ENABLE_ACCOUNT_CREATION === 'true'; -export const facebookClientId = PUBLIC_FACEBOOK_APPID; export const gitHubClientId = PUBLIC_GITHUB_APPID; export const googleClientId = PUBLIC_GOOGLE_APPID; export const microsoftClientId = PUBLIC_MICROSOFT_APPID; export const slackClientId = PUBLIC_SLACK_APPID; -export const enableOAuthLogin = facebookClientId || gitHubClientId || googleClientId || microsoftClientId; +export const enableOAuthLogin = gitHubClientId || googleClientId || microsoftClientId; // OAuth login functions (interactive popup-based, not pure API calls) -export async function facebookLogin(redirectUrl?: string, inviteToken?: null | string) { - if (!facebookClientId) { - throw new Error('Facebook client id not set'); - } - - await oauthLogin({ - authUrl: 'https://www.facebook.com/v2.5/dialog/oauth', - clientId: facebookClientId, - inviteToken, - provider: 'facebook', - redirectUrl, - scope: 'email' - }); -} - export async function githubLogin(redirectUrl?: string, inviteToken?: null | string) { if (!gitHubClientId) { throw new Error('GitHub client id not set'); @@ -133,21 +112,21 @@ export async function gotoLogin() { }); } -export async function liveLogin(redirectUrl?: string, inviteToken?: null | string) { +export async function microsoftLogin(redirectUrl?: string, inviteToken?: null | string) { if (!microsoftClientId) { - throw new Error('Live client id not set'); + throw new Error('Microsoft client id not set'); } await oauthLogin({ - authUrl: 'https://login.live.com/oauth20_authorize.srf', + authUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', clientId: microsoftClientId, extraParams: { - display: 'popup' + state: createOAuthState() }, inviteToken, - provider: 'live', + provider: 'microsoft', redirectUrl, - scope: 'wl.emails' + scope: 'User.Read' }); } @@ -175,21 +154,37 @@ export async function slackOAuthLogin(): Promise { // OAuth helpers +function createOAuthState() { + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + return Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => value.toString(16).padStart(2, '0')).join(''); +} + async function oauthLogin(options: OAuthLoginOptions) { const data = await openOAuthPopup(options); const client = useFetchClient(); - const response = await client.postJSON(`auth/${options.provider}`, { - clientId: options.clientId, - code: data.code, - inviteToken: options.inviteToken, - redirectUri: window.location.origin, - state: data.state - }); + const response = await client.postJSON( + `auth/${options.provider}`, + { + clientId: options.clientId, + code: data.code, + inviteToken: options.inviteToken, + redirectUri: window.location.origin, + state: data.state + }, + { + expectedStatusCodes: [401, 403, 422] + } + ); if (response.ok && response.data?.token) { accessToken.current = response.data.token; await gotoRedirect(options.redirectUrl || resolve('/(app)')); + } else { + toast.error(getProblemMessage(response.problem, 'Unable to sign in. Please try again.')); } } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/microsoft.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/microsoft.test.ts new file mode 100644 index 0000000000..59fd715c6d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/microsoft.test.ts @@ -0,0 +1,141 @@ +import { FetchClient } from '@foundatiofx/fetchclient'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { accessToken, goto, postJSON, toastError } = vi.hoisted(() => ({ + accessToken: { current: null as null | string }, + goto: vi.fn(), + postJSON: vi.fn(), + toastError: vi.fn() +})); + +vi.mock('$app/navigation', () => ({ goto })); +vi.mock('$app/paths', () => ({ resolve: (path: string) => path })); +vi.mock('$app/state', () => ({ page: {} })); +vi.mock('$app/env/public', () => ({ + PUBLIC_ENABLE_ACCOUNT_CREATION: 'true', + PUBLIC_GITHUB_APPID: '', + PUBLIC_GOOGLE_APPID: '', + PUBLIC_MICROSOFT_APPID: 'microsoft-client-id', + PUBLIC_SLACK_APPID: '' +})); +vi.mock('#features/navigation/redirect.ts', () => ({ gotoRedirect: goto })); +vi.mock('@foundatiofx/fetchclient', async (importOriginal) => ({ ...(await importOriginal()), useFetchClient: () => ({ postJSON }) })); +vi.mock('svelte-sonner', () => ({ toast: { error: toastError } })); +vi.mock('./api.svelte', () => ({})); +vi.mock('./state.svelte', () => ({ accessToken })); +vi.mock('./validators', () => ({ validateEmailAvailability: vi.fn() })); + +import { microsoftLogin } from './index.svelte'; + +describe('microsoftLogin', () => { + const popup = { close: vi.fn(), closed: false, focus: vi.fn(), location: new URL('https://login.microsoftonline.com/') }; + const open = vi.fn<(url: string) => typeof popup>().mockReturnValue(popup); + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + accessToken.current = null; + popup.location = new URL('https://login.microsoftonline.com/'); + postJSON.mockResolvedValue({ data: { token: 'session-token' }, ok: true }); + vi.stubGlobal('window', { location: new URL('http://localhost:7131/login'), open, outerHeight: 900, outerWidth: 1200, screenX: 0, screenY: 0 }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it.each([true, false])('validates state and preserves invitation and redirect with randomUUID available: %s', async (hasRandomUUID) => { + const getRandomValues = vi.fn((bytes: Uint8Array) => bytes.fill(10)); + vi.stubGlobal('crypto', { getRandomValues, randomUUID: hasRandomUUID ? () => 'oauth-state' : undefined }); + + const login = microsoftLogin('/organization/invited', 'invitation-token'); + const authorizationUrl = new URL(open.mock.calls[0]![0]); + expect(authorizationUrl.origin + authorizationUrl.pathname).toBe('https://login.microsoftonline.com/common/oauth2/v2.0/authorize'); + expect(authorizationUrl.searchParams.get('client_id')).toBe('microsoft-client-id'); + expect(authorizationUrl.searchParams.get('scope')).toBe('User.Read'); + expect(authorizationUrl.searchParams.get('response_type')).toBe('code'); + expect(authorizationUrl.searchParams.get('redirect_uri')).toBe('http://localhost:7131'); + const state = authorizationUrl.searchParams.get('state'); + expect(state).toBe(hasRandomUUID ? 'oauth-state' : '0a'.repeat(16)); + + popup.location = new URL(`http://localhost:7131/?code=authorization-code&state=${state}`); + await vi.advanceTimersByTimeAsync(500); + await login; + + expect(postJSON).toHaveBeenCalledExactlyOnceWith( + 'auth/microsoft', + { + clientId: 'microsoft-client-id', + code: 'authorization-code', + inviteToken: 'invitation-token', + redirectUri: 'http://localhost:7131', + state + }, + { expectedStatusCodes: [401, 403, 422] } + ); + expect(accessToken.current).toBe('session-token'); + expect(goto).toHaveBeenCalledExactlyOnceWith('/organization/invited'); + expect(popup.close).toHaveBeenCalledOnce(); + }); + + it.each([ + [{ status: 403, title: 'Account Creation is currently disabled' }, 'Account Creation is currently disabled'], + [undefined, 'Unable to sign in. Please try again.'] + ])('displays an unsuccessful exchange without changing the session', async (problem, message) => { + vi.stubGlobal('crypto', { randomUUID: () => 'expected-state' }); + accessToken.current = 'existing-session'; + postJSON.mockResolvedValue({ ok: false, problem }); + const login = microsoftLogin(); + popup.location = new URL('http://localhost:7131/?code=authorization-code&state=expected-state'); + await vi.advanceTimersByTimeAsync(500); + await login; + + expect(toastError).toHaveBeenCalledExactlyOnceWith(message); + expect(accessToken.current).toBe('existing-session'); + expect(goto).not.toHaveBeenCalled(); + expect(popup.close).toHaveBeenCalledOnce(); + }); + + it('displays the 403 with the application error callback enabled', async () => { + const message = 'Account Creation is currently disabled'; + const client = new FetchClient({ + baseUrl: 'http://localhost/api/v2', + defaultRequestOptions: { + errorCallback: (response) => { + throw response.problem ?? response; + } + }, + fetch: async () => + new Response(JSON.stringify({ status: 403, title: message }), { + headers: { 'Content-Type': 'application/problem+json' }, + status: 403 + }) + }); + postJSON.mockImplementation(client.postJSON.bind(client)); + vi.stubGlobal('crypto', { randomUUID: () => 'expected-state' }); + const login = microsoftLogin(); + const completed = expect(login).resolves.toBeUndefined(); + popup.location = new URL('http://localhost:7131/?code=authorization-code&state=expected-state'); + await vi.advanceTimersByTimeAsync(500); + await completed; + expect(toastError).toHaveBeenCalledExactlyOnceWith(message); + expect(accessToken.current).toBeNull(); + expect(goto).not.toHaveBeenCalled(); + }); + + it.each(['state=wrong-state', '', 'error=access_denied'])('does not exchange the code when the callback contains %s', async (query) => { + vi.stubGlobal('crypto', { randomUUID: () => 'expected-state' }); + const login = microsoftLogin(); + const rejected = expect(login).rejects.toThrow(query.includes('error=') ? 'access_denied' : 'Invalid state'); + + popup.location = new URL(`http://localhost:7131/?code=authorization-code&${query}`); + await vi.advanceTimersByTimeAsync(500); + await rejected; + + expect(postJSON).not.toHaveBeenCalled(); + expect(accessToken.current).toBeNull(); + expect(goto).not.toHaveBeenCalled(); + expect(popup.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/icons/FacebookIcon.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/icons/FacebookIcon.svelte deleted file mode 100644 index 2576b2804d..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/icons/FacebookIcon.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/external-logins/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/external-logins/+page.svelte index 399d725359..8f0123fda4 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/external-logins/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/external-logins/+page.svelte @@ -2,7 +2,6 @@ import X from '@lucide/svelte/icons/x'; import { toast } from 'svelte-sonner'; - import FacebookIcon from '#comp/icons/FacebookIcon.svelte'; import GitHubIcon from '#comp/icons/GitHubIcon.svelte'; import GoogleIcon from '#comp/icons/GoogleIcon.svelte'; import MicrosoftIcon from '#comp/icons/MicrosoftIcon.svelte'; @@ -13,14 +12,12 @@ import { unlinkOAuthAccount } from '#features/auth/api.svelte.ts'; import { enableOAuthLogin, - facebookClientId, - facebookLogin, gitHubClientId, githubLogin, googleClientId, googleLogin, - liveLogin, - microsoftClientId + microsoftClientId, + microsoftLogin } from '#features/auth/index.svelte.ts'; import { getMeQuery } from '#features/users/api.svelte.ts'; @@ -44,7 +41,7 @@ const oauthAccounts = $derived(meQuery.data?.o_auth_accounts ?? []); const hasLocalAccount = $derived(meQuery.data?.has_local_account ?? false); - const canUnlinkAccount = $derived(hasLocalAccount || oauthAccounts.length > 1); + const activeOAuthAccounts = $derived(oauthAccounts.filter((account) => !['facebook', 'windowslive'].includes(account.provider.toLowerCase())));
@@ -58,7 +55,7 @@

Add an external login

{#if microsoftClientId} - {/if} @@ -67,11 +64,6 @@ Google {/if} - {#if facebookClientId} - - {/if} {#if gitHubClientId}
{#if microsoftClientId} - {/if} @@ -187,11 +184,6 @@ Google {/if} - {#if facebookClientId} - - {/if} {#if gitHubClientId} {/if} @@ -91,11 +88,6 @@ Google {/if} - {#if facebookClientId} - - {/if} {#if gitHubClientId}