Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build/update-config-next.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions build/update-config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptionless.Job/appsettings.Production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptionless.Job/appsettings.Staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,9 @@ headers api_key input box.
}
});

group.MapPost("live", async (IMediator mediator, IMediatorResultMapper<HttpIResult> resultMapper, HttpContext httpContext, [FromBody] ExternalAuthInfo value) =>
group.MapPost("microsoft", async (IMediator mediator, IMediatorResultMapper<HttpIResult> resultMapper, HttpContext httpContext, [FromBody] ExternalAuthInfo value) =>
Comment thread
niemyjski marked this conversation as resolved.
Comment thread
niemyjski marked this conversation as resolved.
{
return (await mediator.InvokeAsync<Result<TokenResult>>(new AuthMessages.LiveLogin(value, httpContext))).ToHttpResult(resultMapper);
return (await mediator.InvokeAsync<Result<TokenResult>>(new AuthMessages.MicrosoftLogin(value, httpContext))).ToHttpResult(resultMapper);
})
.AllowAnonymous()
.Accepts<ExternalAuthInfo>("application/json", "application/*+json")
Expand All @@ -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",
}
});
Expand Down
51 changes: 45 additions & 6 deletions src/Exceptionless.Web/Api/Handlers/AuthHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ public class AuthHandler(
TimeProvider timeProvider,
ILogger<AuthHandler> 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);
Expand Down Expand Up @@ -285,7 +287,7 @@ public Task<Result<TokenResult>> Handle(FacebookLogin message)
);
}

public Task<Result<TokenResult>> Handle(LiveLogin message)
public Task<Result<TokenResult>> Handle(MicrosoftLogin message)
{
return ExternalLoginAsync(message.AuthInfo, message.Context,
authOptions.MicrosoftId,
Expand Down Expand Up @@ -534,7 +536,11 @@ private async Task<Result<TokenResult>> 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<TokenResult>.FromResult(result);

user = result.Value;
}
catch (ApplicationException ex)
{
Expand All @@ -554,12 +560,13 @@ private async Task<Result<TokenResult>> ExternalLoginAsync(ExternalAuthInfo auth
return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) };
}

private async Task<User> FromExternalLoginAsync(UserInfo userInfo, string? inviteToken, HttpContext httpContext)
private async Task<Result<User>> 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));

Expand All @@ -577,26 +584,38 @@ private async Task<User> 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.");
Comment on lines +616 to +617

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Surface the Microsoft email-match rejection

When a Microsoft identity's email matches an existing account without a modern Microsoft identity—which includes the intended Windows Live migration cohort—this new branch returns the actionable 403 message, but the Svelte oauthLogin helper silently ignores every non-OK response and its login/signup button handlers display no error. The authorization popup therefore closes and leaves the user on the same page without telling them to sign in another way and link Microsoft; propagate response.problem so this expected rejection is visible.

Useful? React with 👍 / 👎.


if (user is null)
{
if (!await IsAccountCreationEnabledAsync(inviteToken))
Expand All @@ -608,17 +627,37 @@ private async Task<User> 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))
await userRepository.AddAsync(user, o => o.Cache());
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<bool> IsAccountCreationEnabledAsync(string? token)
{
if (authOptions.EnableAccountCreation)
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptionless.Web/Api/Messages/AuthMessages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> ProviderUserId, HttpContext Context);
public record ChangePassword(ChangePasswordModel Model, HttpContext Context);
public record CheckEmailAddress(string Email, HttpContext Context);
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptionless.Web/ClientApp.angular/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
FACEBOOK_APPID,
GOOGLE_APPID,
GITHUB_APPID,
LIVE_APPID,
MICROSOFT_APPID,
notificationService,
projectService,
userService,
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,8 @@ <h4>{{::'Add an external login' | translate}}</h4>
<button
type="button"
role="button"
ng-click="vm.authenticate('live')"
ng-if="vm.isExternalLoginEnabled('live')"
ng-click="vm.authenticate('microsoft')"
ng-if="vm.isExternalLoginEnabled('microsoft')"
class="btn btn-large image-button icon-login-microsoft"
title="{{::'Log in using your Microsoft account' | translate}}"
></button>
Expand Down
39 changes: 35 additions & 4 deletions src/Exceptionless.Web/ClientApp.angular/app/auth/auth.js
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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,
Expand All @@ -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", {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
FACEBOOK_APPID,
GOOGLE_APPID,
GITHUB_APPID,
LIVE_APPID,
MICROSOFT_APPID,
ENABLE_ACCOUNT_CREATION,
notificationService,
projectService,
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ <h4>
<button
type="button"
role="button"
ng-click="vm.authenticate('live')"
ng-if="vm.isExternalLoginEnabled('live')"
ng-click="vm.authenticate('microsoft')"
ng-if="vm.isExternalLoginEnabled('microsoft')"
class="btn btn-large image-button icon-login-microsoft"
title="{{::'Log in using your Microsoft account' | translate}}"
></button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
FACEBOOK_APPID,
GOOGLE_APPID,
GITHUB_APPID,
LIVE_APPID,
MICROSOFT_APPID,
notificationService,
projectService,
stateService,
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ <h4 ng-if="vm.isExternalLoginEnabled()">{{::'Login with' | translate}}</h4>
<button
type="button"
role="button"
ng-click="vm.authenticate('live')"
ng-if="vm.isExternalLoginEnabled('live')"
ng-click="vm.authenticate('microsoft')"
ng-if="vm.isExternalLoginEnabled('microsoft')"
class="btn btn-large image-button icon-login-microsoft"
title="{{::'Log in using your Microsoft account' | translate}}"
></button>
Expand Down
Loading
Loading