Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 4 additions & 4 deletions build/update-config-next.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ ClientSetupShowServerUrl="${EX_ClientSetupShowServerUrl:-true}"
EnableAccountCreation="${EX_EnableAccountCreation:-true}"

OAuth="${EX_ConnectionStrings__OAuth:-}"
IFS=';' read -a oauthParts <<< "$OAuth"
for part in ${oauthParts[@]}
IFS=';' read -ra 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"
key="${part%%=*}"
value="${part#*=}"

if [ "$key" == "FacebookId" ]; then
FacebookAppId=$value
Expand Down
10 changes: 5 additions & 5 deletions build/update-config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ EnableSsl="${EX_EnableSsl:-false}"
EnableAccountCreation="${EX_EnableAccountCreation:-true}"

OAuth="${EX_ConnectionStrings__OAuth:-}"
IFS=';' read -a oauthParts <<< "$OAuth"
for part in ${oauthParts[@]}
IFS=';' read -ra 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"
key="${part%%=*}"
value="${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
10 changes: 10 additions & 0 deletions docs/docs/self-hosting/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ Runs Exceptionless without persisting data between runs. Good for checking out E
docker run --rm -it -p 5200:8080 exceptionless/exceptionless:latest
```

## Microsoft Authentication

Create a Microsoft Entra app registration that supports organizational and personal Microsoft accounts. Add your Exceptionless base URL as a Web redirect URI and grant the delegated Microsoft Graph `User.Read` permission.

Pass the client ID and secret through the OAuth connection string:

```bash
-e EX_ConnectionStrings__OAuth="MicrosoftId=YOUR_CLIENT_ID;MicrosoftSecret=YOUR_CLIENT_SECRET"
```

## Simple Setup

Runs a very simple non-production setup for Exceptionless with data persisted between runs in a sub-directory of the current directory called `esdata`. It uses an embedded single node Elasticsearch cluster and does not have backups. It is recommended that you create your own Elasticsearch cluster for production deployments of Exceptionless.
Expand Down
8 changes: 8 additions & 0 deletions docs/docs/self-hosting/kubernetes.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ Please note the following:
4. Update the `exceptionless-config` config map `ConnectionStrings__OAuth` value to include `SlackId=YOUR_ID;SlackSecret=YOUR_SECRET;` and restart the associated pods.
5. If you've already loaded a page in Exceptionless, you will need to do a hard refresh for the config changes to apply.

## Enabling Microsoft Authentication

1. Create a Microsoft Entra app registration that supports organizational and personal Microsoft accounts.
2. Add the Exceptionless base URL as a Web redirect URI.
3. Add the delegated Microsoft Graph `User.Read` permission and create a client secret.
4. Set `config.EX_ConnectionStrings__OAuth` to `MicrosoftId=YOUR_CLIENT_ID;MicrosoftSecret=YOUR_CLIENT_SECRET` and restart the associated pods.
5. Hard refresh Exceptionless after the rollout so both UI configurations pick up the new client ID.

## Upgrading

Please see the [Upgrading](/docs/self-hosting/upgrading-self-hosted-instance) for details on how to upgrade to the current version.
Expand Down
1 change: 1 addition & 0 deletions k8s/exceptionless/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:

config: {}
# key: value
# EX_ConnectionStrings__OAuth: MicrosoftId=YOUR_CLIENT_ID;MicrosoftSecret=YOUR_CLIENT_SECRET

ingress:
enabled: false
Expand Down
1 change: 1 addition & 0 deletions samples/docker-compose.all-in-one.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ services:
- 9200:9200
environment:
EX_AppMode: Production
# EX_ConnectionStrings__OAuth: MicrosoftId=YOUR_CLIENT_ID;MicrosoftSecret=YOUR_CLIENT_SECRET
#ASPNETCORE_URLS: http://+;https://+
#ASPNETCORE_HTTPS_PORT: 7111
#ASPNETCORE_Kestrel__Certificates__Default__Password: password
Expand Down
1 change: 1 addition & 0 deletions samples/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ services:
EX_ConnectionStrings__Cache: provider=redis
EX_ConnectionStrings__Elasticsearch: server=http://elasticsearch:9200
EX_ConnectionStrings__MessageBus: provider=redis
# EX_ConnectionStrings__OAuth: MicrosoftId=YOUR_CLIENT_ID;MicrosoftSecret=YOUR_CLIENT_SECRET
EX_ConnectionStrings__Queue: provider=redis
EX_ConnectionStrings__Redis: redis,abortConnect=false
EX_ConnectionStrings__Storage: provider=folder;path=/app/storage
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;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;SlackId=34500115540.177239122322;

# Base url for the ui used to build links in emails and other places.
BaseURL: https://dev.exceptionless.io
Expand Down
4 changes: 2 additions & 2 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) =>

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 BLOCKER: Keep the legacy live authentication endpoint

During rolling deployments, with cached older frontends, or for external clients still using the published contract, requests continue to target POST /api/v2/auth/live; replacing rather than supplementing that route makes every such login return 404 immediately after the server upgrade. Preserve the legacy endpoint and handler as a compatibility path while adding /auth/microsoft, unless explicit approval for the breaking API change is obtained.

AGENTS.md reference: AGENTS.md:L67-L67

Useful? React with 👍 / 👎.

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 Down
48 changes: 42 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 @@ -577,22 +579,25 @@ private async Task<User> FromExternalLoginAsync(UserInfo userInfo, HttpContext h
}
else
{
return currentUser;
return await UpdateExternalLoginAsync(currentUser, userInfo);
}
}

currentUser.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email);
return await userRepository.SaveAsync(currentUser, o => o.Cache());
return await UpdateExternalLoginAsync(currentUser, userInfo);
}

if (existingUser is not null)
{
bool hasChanges = UpdateExternalLogin(existingUser, userInfo);
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;
}

Expand All @@ -609,7 +614,7 @@ private async Task<User> FromExternalLoginAsync(UserInfo userInfo, HttpContext h
}

user.MarkEmailAddressVerified();
user.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email);
UpdateExternalLogin(user, userInfo);

if (String.IsNullOrEmpty(user.Id))
await userRepository.AddAsync(user, o => o.Cache());
Expand All @@ -619,6 +624,37 @@ private async Task<User> FromExternalLoginAsync(UserInfo userInfo, HttpContext h
return user;
}

private async Task<User> UpdateExternalLoginAsync(User user, UserInfo userInfo)
{
if (UpdateExternalLogin(user, userInfo))
return await userRepository.SaveAsync(user, o => o.Cache());

return user;
}

private static bool UpdateExternalLogin(User user, UserInfo userInfo)
{
bool hasChanges = false;
if (!user.OAuthAccounts.Any(account =>
String.Equals(account.Provider, userInfo.ProviderName, StringComparison.OrdinalIgnoreCase)
&& account.ProviderUserId == userInfo.Id))
{
user.AddOAuthAccount(userInfo.ProviderName!, userInfo.Id!, userInfo.Email!);
hasChanges = true;
}

if (!String.Equals(userInfo.ProviderName, MicrosoftOAuthProvider, StringComparison.OrdinalIgnoreCase))
return hasChanges;

var legacyAccounts = user.OAuthAccounts
.Where(account => String.Equals(account.Provider, LegacyMicrosoftOAuthProvider, StringComparison.OrdinalIgnoreCase))
.ToArray();
foreach (var account in legacyAccounts)
user.OAuthAccounts.Remove(account);

return hasChanges || 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 Expand Up @@ -385,8 +385,8 @@ <h4>{{::'Existing external logins' | translate}}</h4>
ng-if="vm.user.o_auth_accounts.length > 0"
>
<td>
{{::account.provider}} ({{::account.username ||
account.provider_user_id}})
{{::account.provider === 'windowslive' ? 'Microsoft (legacy)' :
account.provider}} ({{::account.username || account.provider_user_id}})
</td>
<td>
<button
Expand Down
26 changes: 22 additions & 4 deletions src/Exceptionless.Web/ClientApp.angular/app/auth/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,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 +42,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 window.crypto.randomUUID();
Comment thread
niemyjski marked this conversation as resolved.
},
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
Loading
Loading