diff --git a/build/update-config-next.sh b/build/update-config-next.sh index 6eac88f9b8..49fb512933 100644 --- a/build/update-config-next.sh +++ b/build/update-config-next.sh @@ -9,7 +9,7 @@ IFS=';' read -a oauthParts <<< "$OAuth" for part in ${oauthParts[@]} do key="$( cut -d '=' -f 1 <<< $part )"; echo "key: $key" - value="$( cut -d '=' -f 2- <<< $part )"; echo "value: $value" + value="$( cut -d '=' -f 2- <<< $part )" if [ "$key" == "FacebookId" ]; then FacebookAppId=$value diff --git a/build/update-config.sh b/build/update-config.sh index b30349125c..276f739ffb 100644 --- a/build/update-config.sh +++ b/build/update-config.sh @@ -10,7 +10,7 @@ IFS=';' read -a oauthParts <<< "$OAuth" for part in ${oauthParts[@]} do key="$( cut -d '=' -f 1 <<< $part )"; echo "key: $key" - value="$( cut -d '=' -f 2- <<< $part )"; echo "value: $value" + value="$( cut -d '=' -f 2- <<< $part )" if [ "$key" == "FacebookId" ]; then FacebookAppId=$value @@ -45,7 +45,7 @@ config=" .constant('GITHUB_APPID', '$GitHubAppId') .constant('GOOGLE_APPID', '$GoogleAppId') .constant('INTERCOM_APPID', '$IntercomAppId') - .constant('LIVE_APPID', '$MicrosoftAppId') + .constant('MICROSOFT_APPID', '$MicrosoftAppId') .constant('SLACK_APPID', '$SlackAppId') .constant('STRIPE_PUBLISHABLE_KEY', '$EX_StripePublishableApiKey') .constant('SYSTEM_NOTIFICATION_MESSAGE', '$EX_NotificationMessage') diff --git a/src/Exceptionless.Job/appsettings.Production.yml b/src/Exceptionless.Job/appsettings.Production.yml index b01a5221e1..54a50b1916 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: FacebookId=395178683904310;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 db415b6211..8f7dbd5a17 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: FacebookId=395178683904310;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.exceptionless.io diff --git a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs index 7b1fc2b41a..afd5f85c18 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs @@ -151,9 +151,9 @@ headers api_key input box. } }); - group.MapPost("live", 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.LiveLogin(value, httpContext))).ToHttpResult(resultMapper); + return (await mediator.InvokeAsync>(new AuthMessages.MicrosoftLogin(value, httpContext))).ToHttpResult(resultMapper); }) .AllowAnonymous() .Accepts("application/json", "application/*+json") @@ -164,7 +164,7 @@ headers api_key input box. .WithMetadata(new EndpointDocumentation { ResponseDescriptions = new() { ["200"] = "User Authentication Token", - ["403"] = "Account Creation is currently disabled", + ["403"] = "Account creation is disabled or the existing account must be signed into before linking Microsoft", ["422"] = "Validation error", } }); diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index e3c73ead83..aff91a0964 100644 --- a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -35,6 +35,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); @@ -285,7 +287,7 @@ public Task> Handle(FacebookLogin message) ); } - public Task> Handle(LiveLogin message) + public Task> Handle(MicrosoftLogin message) { return ExternalLoginAsync(message.AuthInfo, message.Context, authOptions.MicrosoftId, @@ -534,7 +536,11 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth User? user; try { - user = await FromExternalLoginAsync(userInfo, authInfo.InviteToken, httpContext); + var result = await FromExternalLoginAsync(userInfo, authInfo.InviteToken, httpContext); + if (!result.IsSuccess) + return Result.FromResult(result); + + user = result.Value; } catch (ApplicationException ex) { @@ -554,12 +560,13 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }; } - private async Task FromExternalLoginAsync(UserInfo userInfo, string? inviteToken, HttpContext httpContext) + private async Task> FromExternalLoginAsync(UserInfo userInfo, string? inviteToken, HttpContext httpContext) { ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Id); ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.ProviderName); ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Email); + bool isMicrosoft = String.Equals(userInfo.ProviderName, MicrosoftOAuthProvider, StringComparison.OrdinalIgnoreCase); var existingUser = await userRepository.GetUserByOAuthProviderAsync(userInfo.ProviderName, userInfo.Id); using var _ = logger.BeginScope(new ExceptionlessState().Tag("External Login").Tag(userInfo.ProviderName).Identity(userInfo.Email).SetHttpContext(httpContext)); @@ -577,26 +584,38 @@ 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) { - if (!existingUser.IsEmailAddressVerified) + bool hasChanges = RemoveLegacyMicrosoftOAuthAccounts(existingUser, userInfo.ProviderName); + if (!isMicrosoft && !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; + // Microsoft Graph mail is editable and does not prove ownership of an existing account. + if (isMicrosoft && user is not null) + return Result.Forbidden("Sign in to your existing account first, then link Microsoft from your account settings."); + if (user is null) { if (!await IsAccountCreationEnabledAsync(inviteToken)) @@ -608,7 +627,10 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit await AddGlobalAdminRoleIfFirstUserAsync(user); } - user.MarkEmailAddressVerified(); + if (isMicrosoft) + user.ResetVerifyEmailAddressTokenAndExpiration(timeProvider); + else + user.MarkEmailAddressVerified(); user.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email); if (String.IsNullOrEmpty(user.Id)) @@ -616,9 +638,26 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit else await userRepository.SaveAsync(user, o => o.Cache()); + if (isMicrosoft) + await mailer.SendUserEmailVerifyAsync(user); + 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..cd43fd7a93 100644 --- a/src/Exceptionless.Web/Api/Messages/AuthMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/AuthMessages.cs @@ -9,7 +9,7 @@ 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.angular/app.config.js b/src/Exceptionless.Web/ClientApp.angular/app.config.js index fb2adddd31..584726e05a 100644 --- a/src/Exceptionless.Web/ClientApp.angular/app.config.js +++ b/src/Exceptionless.Web/ClientApp.angular/app.config.js @@ -10,7 +10,7 @@ .constant("GITHUB_APPID") .constant("GOOGLE_APPID") .constant("INTERCOM_APPID") - .constant("LIVE_APPID") + .constant("MICROSOFT_APPID") .constant("SLACK_APPID") .constant("STRIPE_PUBLISHABLE_KEY") .constant("SYSTEM_NOTIFICATION_MESSAGE") diff --git a/src/Exceptionless.Web/ClientApp.angular/app/account/manage-controller.js b/src/Exceptionless.Web/ClientApp.angular/app/account/manage-controller.js index 8ac0d5e672..57e8f3c7da 100644 --- a/src/Exceptionless.Web/ClientApp.angular/app/account/manage-controller.js +++ b/src/Exceptionless.Web/ClientApp.angular/app/account/manage-controller.js @@ -14,7 +14,7 @@ FACEBOOK_APPID, GOOGLE_APPID, GITHUB_APPID, - LIVE_APPID, + MICROSOFT_APPID, notificationService, projectService, userService, @@ -194,7 +194,7 @@ function isExternalLoginEnabled(provider) { if (!provider) { - return !!FACEBOOK_APPID || !!GITHUB_APPID || !!GOOGLE_APPID || !!LIVE_APPID; + return !!FACEBOOK_APPID || !!GITHUB_APPID || !!GOOGLE_APPID || !!MICROSOFT_APPID; } switch (provider) { @@ -204,8 +204,8 @@ return !!GITHUB_APPID; case "google": return !!GOOGLE_APPID; - case "live": - return !!LIVE_APPID; + case "microsoft": + return !!MICROSOFT_APPID; default: return false; } diff --git a/src/Exceptionless.Web/ClientApp.angular/app/account/manage.tpl.html b/src/Exceptionless.Web/ClientApp.angular/app/account/manage.tpl.html index a0c4f13344..93fa2b4a2b 100644 --- a/src/Exceptionless.Web/ClientApp.angular/app/account/manage.tpl.html +++ b/src/Exceptionless.Web/ClientApp.angular/app/account/manage.tpl.html @@ -339,8 +339,8 @@

{{::'Add an external login' | translate}}

diff --git a/src/Exceptionless.Web/ClientApp.angular/app/auth/auth.js b/src/Exceptionless.Web/ClientApp.angular/app/auth/auth.js index d9b9a0f259..6c08e60353 100644 --- a/src/Exceptionless.Web/ClientApp.angular/app/auth/auth.js +++ b/src/Exceptionless.Web/ClientApp.angular/app/auth/auth.js @@ -1,6 +1,19 @@ (function () { "use strict"; + function createOAuthState() { + if (typeof window.crypto.randomUUID === "function") { + return window.crypto.randomUUID(); + } + + var bytes = window.crypto.getRandomValues(new Uint8Array(16)); + return Array.prototype.map + .call(bytes, function (value) { + return ("0" + value.toString(16)).slice(-2); + }) + .join(""); + } + angular .module("app.auth", [ "directives.inputMatch", @@ -20,7 +33,15 @@ "exceptionless.validators", ]) .config( - function ($authProvider, $stateProvider, BASE_URL, FACEBOOK_APPID, GOOGLE_APPID, GITHUB_APPID, LIVE_APPID) { + function ( + $authProvider, + $stateProvider, + BASE_URL, + FACEBOOK_APPID, + GOOGLE_APPID, + GITHUB_APPID, + MICROSOFT_APPID + ) { $authProvider.baseUrl = BASE_URL + "/api/v2"; $authProvider.facebook({ clientId: FACEBOOK_APPID, @@ -34,9 +55,19 @@ clientId: GITHUB_APPID, }); - $authProvider.live({ - clientId: LIVE_APPID, - scope: ["wl.emails"], + $authProvider.oauth2({ + name: "microsoft", + url: "/auth/microsoft", + authorizationEndpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + clientId: MICROSOFT_APPID, + redirectUri: window.location.origin, + requiredUrlParams: ["scope", "state"], + scope: ["User.Read"], + scopeDelimiter: " ", + state: function () { + return createOAuthState(); + }, + popupOptions: { width: 500, height: 560 }, }); $stateProvider.state("auth", { diff --git a/src/Exceptionless.Web/ClientApp.angular/app/auth/login-controller.js b/src/Exceptionless.Web/ClientApp.angular/app/auth/login-controller.js index 03ba798074..1ffd682049 100644 --- a/src/Exceptionless.Web/ClientApp.angular/app/auth/login-controller.js +++ b/src/Exceptionless.Web/ClientApp.angular/app/auth/login-controller.js @@ -15,7 +15,7 @@ FACEBOOK_APPID, GOOGLE_APPID, GITHUB_APPID, - LIVE_APPID, + MICROSOFT_APPID, ENABLE_ACCOUNT_CREATION, notificationService, projectService, @@ -64,7 +64,7 @@ function isExternalLoginEnabled(provider) { if (!provider) { - return !!FACEBOOK_APPID || !!GITHUB_APPID || !!GOOGLE_APPID || !!LIVE_APPID; + return !!FACEBOOK_APPID || !!GITHUB_APPID || !!GOOGLE_APPID || !!MICROSOFT_APPID; } switch (provider) { @@ -74,8 +74,8 @@ return !!GITHUB_APPID; case "google": return !!GOOGLE_APPID; - case "live": - return !!LIVE_APPID; + case "microsoft": + return !!MICROSOFT_APPID; default: return false; } diff --git a/src/Exceptionless.Web/ClientApp.angular/app/auth/login.tpl.html b/src/Exceptionless.Web/ClientApp.angular/app/auth/login.tpl.html index f26f7dc9dc..53f14b1d14 100644 --- a/src/Exceptionless.Web/ClientApp.angular/app/auth/login.tpl.html +++ b/src/Exceptionless.Web/ClientApp.angular/app/auth/login.tpl.html @@ -32,8 +32,8 @@

diff --git a/src/Exceptionless.Web/ClientApp.angular/app/auth/signup-controller.js b/src/Exceptionless.Web/ClientApp.angular/app/auth/signup-controller.js index c108efd98f..bd4d3db1ec 100644 --- a/src/Exceptionless.Web/ClientApp.angular/app/auth/signup-controller.js +++ b/src/Exceptionless.Web/ClientApp.angular/app/auth/signup-controller.js @@ -18,7 +18,7 @@ FACEBOOK_APPID, GOOGLE_APPID, GITHUB_APPID, - LIVE_APPID, + MICROSOFT_APPID, notificationService, projectService, stateService, @@ -65,7 +65,7 @@ function isExternalLoginEnabled(provider) { if (!provider) { - return !!FACEBOOK_APPID || !!GITHUB_APPID || !!GOOGLE_APPID || !!LIVE_APPID; + return !!FACEBOOK_APPID || !!GITHUB_APPID || !!GOOGLE_APPID || !!MICROSOFT_APPID; } switch (provider) { @@ -75,8 +75,8 @@ return !!GITHUB_APPID; case "google": return !!GOOGLE_APPID; - case "live": - return !!LIVE_APPID; + case "microsoft": + return !!MICROSOFT_APPID; default: return false; } diff --git a/src/Exceptionless.Web/ClientApp.angular/app/auth/signup.tpl.html b/src/Exceptionless.Web/ClientApp.angular/app/auth/signup.tpl.html index d3dd2537e2..4e6f3f19da 100644 --- a/src/Exceptionless.Web/ClientApp.angular/app/auth/signup.tpl.html +++ b/src/Exceptionless.Web/ClientApp.angular/app/auth/signup.tpl.html @@ -32,8 +32,8 @@

{{::'Login with' | translate}}

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 406b926252..a9806c8763 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 @@ -45,7 +45,7 @@ export interface OAuthResponseData { state: string; } -export type SupportedOAuthProviders = 'facebook' | 'github' | 'google' | 'live' | 'slack'; +export type SupportedOAuthProviders = 'facebook' | 'github' | 'google' | 'microsoft' | 'slack'; export const enableAccountCreation = env.PUBLIC_ENABLE_ACCOUNT_CREATION === 'true'; export const facebookClientId = env.PUBLIC_FACEBOOK_APPID; @@ -124,21 +124,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' }); } @@ -166,6 +166,14 @@ 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); 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..57cb0ba44e --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/microsoft.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { accessToken, goto, postJSON } = vi.hoisted(() => ({ + accessToken: { current: null as null | string }, + goto: vi.fn(), + postJSON: vi.fn() +})); + +vi.mock('$app/navigation', () => ({ goto })); +vi.mock('$app/paths', () => ({ resolve: (path: string) => `/next${path}` })); +vi.mock('$app/state', () => ({ page: {} })); +vi.mock('$env/dynamic/public', () => ({ env: { PUBLIC_MICROSOFT_APPID: 'microsoft-client-id' } })); +vi.mock('@foundatiofx/fetchclient', () => ({ useFetchClient: () => ({ postJSON }) })); +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/next/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('/next/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 + }); + expect(accessToken.current).toBe('session-token'); + expect(goto).toHaveBeenCalledExactlyOnceWith('/next/organization/invited'); + expect(popup.close).toHaveBeenCalledOnce(); + }); + + 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/routes/(app)/account/external-logins/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/external-logins/+page.svelte index d083754acc..cfba077644 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 @@ -16,8 +16,8 @@ githubLogin, googleClientId, googleLogin, - liveLogin, - microsoftClientId + microsoftClientId, + microsoftLogin } from '$features/auth/index.svelte'; import { getMeQuery } from '$features/users/api.svelte'; import X from '@lucide/svelte/icons/x'; @@ -57,7 +57,7 @@

Add an external login

{#if microsoftClientId} - {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/login/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/login/+page.svelte index 555609f873..9db53572db 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/login/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/login/+page.svelte @@ -26,9 +26,9 @@ githubLogin, googleClientId, googleLogin, - liveLogin, logout, - microsoftClientId + microsoftClientId, + microsoftLogin } from '$features/auth/index.svelte'; import { type LoginFormData, LoginSchema } from '$features/auth/schemas'; import { getSafeRedirectUrl } from '$features/shared/url'; @@ -177,7 +177,7 @@
{#if microsoftClientId} - {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/signup/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/signup/+page.svelte index 29c7c28133..a048f054d1 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/signup/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/signup/+page.svelte @@ -25,9 +25,9 @@ githubLogin, googleClientId, googleLogin, - liveLogin, logout, - microsoftClientId + microsoftClientId, + microsoftLogin } from '$features/auth/index.svelte'; import { type SignupFormData, SignupSchema } from '$features/auth/schemas'; import { validateEmailAvailability } from '$features/auth/validators'; @@ -81,7 +81,7 @@

Sign up with

{#if microsoftClientId} - {/if} diff --git a/src/Exceptionless.Web/Security/OAuthProviderClient.cs b/src/Exceptionless.Web/Security/OAuthProviderClient.cs index c7427390a2..7a8a0b637d 100644 --- a/src/Exceptionless.Web/Security/OAuthProviderClient.cs +++ b/src/Exceptionless.Web/Security/OAuthProviderClient.cs @@ -52,8 +52,8 @@ public Task GetMicrosoftUserInfoAsync(ExternalAuthInfo authInfo, strin { return GetUserInfoAsync(authInfo, appId, appSecret, (factory, configuration) => { - configuration.Scope = "wl.emails"; - return new WindowsLiveClient(factory, configuration); + configuration.Scope = "User.Read"; + return new MicrosoftClient(factory, configuration); }); } diff --git a/src/Exceptionless.Web/appsettings.Production.yml b/src/Exceptionless.Web/appsettings.Production.yml index a6bbd82962..2e8f317ee7 100644 --- a/src/Exceptionless.Web/appsettings.Production.yml +++ b/src/Exceptionless.Web/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: FacebookId=395178683904310;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.Web/appsettings.Staging.yml b/src/Exceptionless.Web/appsettings.Staging.yml index 9d97c7b5a4..3f4863e01d 100644 --- a/src/Exceptionless.Web/appsettings.Staging.yml +++ b/src/Exceptionless.Web/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: FacebookId=395178683904310;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.exceptionless.io diff --git a/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json index 01cc5636a7..af8b657897 100644 --- a/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json +++ b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json @@ -739,8 +739,8 @@ }, { "method": "POST", - "route": "/api/v2/auth/live", - "displayName": "HTTP: POST api/v2/auth/live", + "route": "/api/v2/auth/login", + "displayName": "HTTP: POST api/v2/auth/login", "tags": [ "Auth" ], @@ -752,13 +752,13 @@ "authenticationSchemes": [] }, { - "method": "POST", - "route": "/api/v2/auth/login", - "displayName": "HTTP: POST api/v2/auth/login", + "method": "GET", + "route": "/api/v2/auth/logout", + "displayName": "HTTP: GET api/v2/auth/logout", "tags": [ "Auth" ], - "allowAnonymous": true, + "allowAnonymous": false, "authorizationPolicies": [ "UserPolicy" ], @@ -766,13 +766,13 @@ "authenticationSchemes": [] }, { - "method": "GET", - "route": "/api/v2/auth/logout", - "displayName": "HTTP: GET api/v2/auth/logout", + "method": "POST", + "route": "/api/v2/auth/microsoft", + "displayName": "HTTP: POST api/v2/auth/microsoft", "tags": [ "Auth" ], - "allowAnonymous": false, + "allowAnonymous": true, "authorizationPolicies": [ "UserPolicy" ], diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 6e88d09679..950b229a61 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -1745,7 +1745,7 @@ } } }, - "/api/v2/auth/live": { + "/api/v2/auth/microsoft": { "post": { "tags": [ "Auth" @@ -1778,7 +1778,7 @@ } }, "403": { - "description": "Account Creation is currently disabled", + "description": "Account creation is disabled or the existing account must be signed into before linking Microsoft", "content": { "application/problem\u002Bjson": { "schema": { diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index 1cc6d7a85f..72a287f50b 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -548,16 +548,16 @@ public async Task GoogleAsync_WithConfiguredProvider_ReturnsToken() } [Fact] - public async Task LiveAsync_WithConfiguredProvider_ReturnsToken() + public async Task MicrosoftAsync_WithConfiguredProvider_ReturnsToken() { // Arrange - const string code = "live-user"; + const string code = "microsoft-user"; // Act - var result = await SendExternalLoginAsync("live", code); + var result = await SendExternalLoginAsync("microsoft", code); // Assert - await AssertExternalLoginAsync(result, "windowslive", code); + await AssertExternalLoginAsync(result, "microsoft", code, isEmailVerified: false); } [Fact] @@ -1554,14 +1554,14 @@ await SendRequestAsync(r => r Assert.False(token.IsSuspended); } - private async Task AssertExternalLoginAsync(TokenResult? result, string providerName, string providerUserId) + private async Task AssertExternalLoginAsync(TokenResult? result, string providerName, string providerUserId, bool isEmailVerified = true) { Assert.NotNull(result); Assert.False(String.IsNullOrEmpty(result.Token)); var user = await _userRepository.GetByEmailAddressAsync(TestOAuthProviderClient.GetEmailAddress(providerUserId)); Assert.NotNull(user); - Assert.True(user.IsEmailAddressVerified); + Assert.Equal(isEmailVerified, user.IsEmailAddressVerified); var account = Assert.Single(user.OAuthAccounts); Assert.Equal(providerName, account.Provider); Assert.Equal(providerUserId, account.ProviderUserId); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/MicrosoftAuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/MicrosoftAuthEndpointTests.cs new file mode 100644 index 0000000000..cd3e91e5ee --- /dev/null +++ b/tests/Exceptionless.Tests/Api/Endpoints/MicrosoftAuthEndpointTests.cs @@ -0,0 +1,189 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Configuration; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Extensions; +using Exceptionless.Tests.Utility; +using Exceptionless.Web.Models; +using FluentRest; +using Foundatio.Repositories; +using Xunit; +using ProblemDetails = Microsoft.AspNetCore.Mvc.ProblemDetails; + +namespace Exceptionless.Tests.Api.Endpoints; + +public sealed class MicrosoftAuthEndpointTests : IntegrationTestsBase +{ + private readonly AuthOptions _authOptions; + private readonly bool _originalEnableAccountCreation; + private readonly string? _originalMicrosoftId; + private readonly string? _originalMicrosoftSecret; + private readonly IUserRepository _userRepository; + + public MicrosoftAuthEndpointTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _authOptions = GetService(); + _originalEnableAccountCreation = _authOptions.EnableAccountCreation; + _originalMicrosoftId = _authOptions.MicrosoftId; + _originalMicrosoftSecret = _authOptions.MicrosoftSecret; + _userRepository = GetService(); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + _authOptions.EnableAccountCreation = true; + _authOptions.MicrosoftId = "microsoft-client-id"; + _authOptions.MicrosoftSecret = "microsoft-client-secret"; + await GetService().CreateDataAsync(); + } + + public override ValueTask DisposeAsync() + { + _authOptions.EnableAccountCreation = _originalEnableAccountCreation; + _authOptions.MicrosoftId = _originalMicrosoftId; + _authOptions.MicrosoftSecret = _originalMicrosoftSecret; + return base.DisposeAsync(); + } + + [Fact] + public async Task MicrosoftAsync_AuthenticatedLinkWithDifferentEmail_ReplacesLegacyIdentity() + { + // Arrange + const string code = "authenticated-microsoft-user"; + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + user.AddOAuthAccount("WindowsLive", "legacy-user", user.EmailAddress); + await _userRepository.SaveAsync(user, o => o.ImmediateConsistency()); + + // Act + await SendMicrosoftLoginAsync(code, isAuthenticated: true); + + // Assert + var updatedUser = await _userRepository.GetByIdAsync(user.Id); + Assert.NotNull(updatedUser); + Assert.Equal(SampleDataService.TEST_ORG_USER_EMAIL, updatedUser.EmailAddress); + var account = Assert.Single(updatedUser.OAuthAccounts); + Assert.Equal("microsoft", account.Provider); + Assert.Equal(code, account.ProviderUserId); + Assert.Equal(TestOAuthProviderClient.GetEmailAddress(code), account.Username); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task MicrosoftAsync_ExistingModernIdentity_PreservesEmailVerification(bool isEmailVerified) + { + // Arrange + const string code = "existing-microsoft-user"; + var user = CreateUser(TestOAuthProviderClient.GetEmailAddress(code)); + if (!isEmailVerified) + user.ResetVerifyEmailAddressTokenAndExpiration(TimeProvider); + string? verificationToken = user.VerifyEmailAddressToken; + user.AddOAuthAccount("WindowsLive", "legacy-user", user.EmailAddress); + user.AddOAuthAccount("Microsoft", code, user.EmailAddress); + await _userRepository.AddAsync(user, o => o.ImmediateConsistency()); + + // Act + await SendMicrosoftLoginAsync(code); + + // Assert + var updatedUser = await _userRepository.GetByIdAsync(user.Id); + Assert.NotNull(updatedUser); + var account = Assert.Single(updatedUser.OAuthAccounts); + Assert.Equal("microsoft", account.Provider); + Assert.Equal(code, account.ProviderUserId); + Assert.Equal(isEmailVerified, updatedUser.IsEmailAddressVerified); + Assert.Equal(verificationToken, updatedUser.VerifyEmailAddressToken); + } + + [Fact] + public async Task MicrosoftAsync_ExactEmailMatch_RequiresAuthenticatedLinkWithoutChangingUser() + { + // Arrange + const string code = "matching-email-user"; + var user = CreateUser(TestOAuthProviderClient.GetEmailAddress(code)); + user.AddOAuthAccount("WindowsLive", "legacy-user", user.EmailAddress); + await _userRepository.AddAsync(user, o => o.ImmediateConsistency()); + + // Act + var problem = await SendRequestAsAsync(request => request + .Post() + .AppendPaths("auth", "microsoft") + .Content(new ExternalAuthInfo { ClientId = "microsoft-client-id", Code = code, RedirectUri = "http://localhost" }) + .StatusCodeShouldBeForbidden()); + + // Assert + var updatedUser = await _userRepository.GetByEmailAddressAsync(user.EmailAddress); + Assert.NotNull(updatedUser); + Assert.Equal(user.Id, updatedUser.Id); + var account = Assert.Single(updatedUser.OAuthAccounts); + Assert.Equal("windowslive", account.Provider); + Assert.Equal("legacy-user", account.ProviderUserId); + Assert.NotNull(problem); + Assert.Contains("link Microsoft", problem.Title); + } + + [Fact] + public async Task MicrosoftAsync_UnmatchedEmail_CreatesNewUserWithoutChangingLegacyUser() + { + // Arrange + const string code = "different-email-user"; + var legacyUser = CreateUser("legacy-user@exceptionless.test"); + legacyUser.AddOAuthAccount("WindowsLive", "legacy-user", legacyUser.EmailAddress); + await _userRepository.AddAsync(legacyUser, o => o.ImmediateConsistency()); + + // Act + await SendMicrosoftLoginAsync(code); + + // Assert + var unchangedLegacyUser = await _userRepository.GetByIdAsync(legacyUser.Id); + Assert.NotNull(unchangedLegacyUser); + var legacyAccount = Assert.Single(unchangedLegacyUser.OAuthAccounts); + Assert.Equal("windowslive", legacyAccount.Provider); + + var microsoftUser = await _userRepository.GetByEmailAddressAsync(TestOAuthProviderClient.GetEmailAddress(code)); + Assert.NotNull(microsoftUser); + Assert.NotEqual(legacyUser.Id, microsoftUser.Id); + var microsoftAccount = Assert.Single(microsoftUser.OAuthAccounts); + Assert.Equal("microsoft", microsoftAccount.Provider); + Assert.Equal(code, microsoftAccount.ProviderUserId); + Assert.False(microsoftUser.IsEmailAddressVerified); + Assert.False(String.IsNullOrWhiteSpace(microsoftUser.VerifyEmailAddressToken)); + Assert.True(microsoftUser.VerifyEmailAddressTokenExpiration > TimeProvider.GetUtcNow().UtcDateTime); + } + + private static User CreateUser(string emailAddress) + { + var user = new User + { + EmailAddress = emailAddress, + FullName = "Microsoft User", + Roles = new HashSet { AuthorizationRoles.Client, AuthorizationRoles.User } + }; + user.MarkEmailAddressVerified(); + return user; + } + + private Task SendMicrosoftLoginAsync(string code, bool isAuthenticated = false) + { + return SendRequestAsAsync(request => + { + request + .Post() + .AppendPaths("auth", "microsoft") + .Content(new ExternalAuthInfo + { + ClientId = "microsoft-client-id", + Code = code, + RedirectUri = "http://localhost/callback" + }) + .StatusCodeShouldBeOk(); + + if (isAuthenticated) + request.AsTestOrganizationUser(); + }); + } +} diff --git a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs index 8aa22eca18..4735a13315 100644 --- a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs +++ b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs @@ -2,6 +2,7 @@ using Exceptionless.Core; using Exceptionless.Core.Authentication; using Exceptionless.Core.Configuration; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Mail; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; @@ -12,6 +13,7 @@ using Foundatio.Caching; using Foundatio.Mediator; using Microsoft.AspNetCore.Http; +using OAuth2.Models; using Xunit; namespace Exceptionless.Tests.Api.Handlers; @@ -44,11 +46,59 @@ public async Task Handle_LoginRepositoryException_ReturnsUnauthorizedResult(Exce new OperationCanceledException("Repository operation was canceled.") }; + [Theory] + [InlineData(null)] + [InlineData("invitation-token")] + public async Task Handle_MicrosoftEmailMatchesUnlinkedUser_DoesNotModifyAccount(string? inviteToken) + { + // Arrange + var userRepository = DispatchProxy.Create(); + var repository = (EmailMatchUserRepositoryProxy)(object)userRepository; + repository.User = new User { Id = "existing-user", EmailAddress = "matching-user@exceptionless.test" }; + repository.User.AddOAuthAccount("WindowsLive", "legacy-user", repository.User.EmailAddress); + var handler = CreateHandler(userRepository); + + // Act + var result = await handler.Handle(new MicrosoftLogin( + new ExternalAuthInfo { ClientId = "microsoft-client-id", Code = "matching-user", RedirectUri = "http://localhost", InviteToken = inviteToken }, + new DefaultHttpContext())); + + // Assert + Assert.Equal(ResultStatus.Forbidden, result.Status); + Assert.Contains("link", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("windowslive", Assert.Single(repository.User.OAuthAccounts).Provider); + Assert.False(repository.User.IsEmailAddressVerified); + } + + [Fact] + public async Task Handle_MicrosoftProviderFails_DoesNotAccessUserRepository() + { + // Arrange + var userRepository = DispatchProxy.Create(); + var oauthProvider = DispatchProxy.Create(); + var handler = CreateHandler(userRepository, oauthProvider); + + // Act + await Assert.ThrowsAsync(() => handler.Handle(new MicrosoftLogin( + new ExternalAuthInfo { ClientId = "microsoft-client-id", Code = "provider-failure", RedirectUri = "http://localhost" }, + new DefaultHttpContext()))); + + // Assert + Assert.Equal(0, ((EmailMatchUserRepositoryProxy)(object)userRepository).CallCount); + } + private AuthHandler CreateHandler(Exception repositoryException) { var userRepository = DispatchProxy.Create(); ((ThrowingUserRepositoryProxy)(object)userRepository).Exception = repositoryException; + return CreateHandler(userRepository); + } + + private AuthHandler CreateHandler(IUserRepository userRepository, IOAuthProviderClient? oauthProvider = null) + { var appOptions = GetService(); + appOptions.AuthOptions.MicrosoftId = "microsoft-client-id"; + appOptions.AuthOptions.MicrosoftSecret = "microsoft-client-secret"; return new AuthHandler( appOptions.AuthOptions, @@ -57,7 +107,7 @@ private AuthHandler CreateHandler(Exception repositoryException) userRepository, GetService(), GetService(), - GetService(), + oauthProvider ?? GetService(), GetService(), GetService(), GetService(), @@ -65,6 +115,34 @@ private AuthHandler CreateHandler(Exception repositoryException) Log.CreateLogger()); } + private class EmailMatchUserRepositoryProxy : DispatchProxy + { + public User User { get; set; } = null!; + public int CallCount { get; private set; } + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + CallCount++; + return targetMethod?.Name switch + { + nameof(IUserRepository.GetUserByOAuthProviderAsync) => Task.FromResult(null), + nameof(IUserRepository.GetByEmailAddressAsync) => Task.FromResult(User), + _ => throw new NotSupportedException($"Unexpected repository call: {targetMethod?.Name}") + }; + } + } + + private class FailingOAuthProviderProxy : DispatchProxy + { + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod?.Name == nameof(IOAuthProviderClient.GetMicrosoftUserInfoAsync)) + return Task.FromException(new InvalidOperationException("Microsoft provider failed.")); + + throw new NotSupportedException($"Unexpected provider call: {targetMethod?.Name}"); + } + } + private class ThrowingUserRepositoryProxy : DispatchProxy { public Exception Exception { get; set; } = null!; diff --git a/tests/Exceptionless.Tests/Utility/TestOAuthProviderClient.cs b/tests/Exceptionless.Tests/Utility/TestOAuthProviderClient.cs index 969236d4a0..41cee96fe6 100644 --- a/tests/Exceptionless.Tests/Utility/TestOAuthProviderClient.cs +++ b/tests/Exceptionless.Tests/Utility/TestOAuthProviderClient.cs @@ -24,7 +24,7 @@ public Task GetGoogleUserInfoAsync(ExternalAuthInfo authInfo, string a public Task GetMicrosoftUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret) { - return GetUserInfoAsync("WindowsLive", authInfo); + return GetUserInfoAsync("Microsoft", authInfo); } public Task GetSlackAccessTokenAsync(string code) diff --git a/tests/http/auth.http b/tests/http/auth.http index a2bf9a2adb..90eb4a9b45 100644 --- a/tests/http/auth.http +++ b/tests/http/auth.http @@ -46,6 +46,29 @@ Content-Type: application/json "redirectUri": "http://localhost" } +### Microsoft OAuth Login +# Existing accounts must sign in first and link Microsoft with their Bearer token. +# An email match alone returns 403; new accounts receive an email verification message. +POST {{apiUrl}}/auth/microsoft +Content-Type: application/json + +{ + "code": "code", + "clientId": "clientId", + "redirectUri": "http://localhost" +} + +### Link Microsoft to the authenticated account +POST {{apiUrl}}/auth/microsoft +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "code": "code", + "clientId": "clientId", + "redirectUri": "http://localhost" +} + ### Intercom token GET {{apiUrl}}/auth/intercom Authorization: Bearer {{token}}