Skip to content
Merged
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ The authentication server consists of two parts:

The description of the API endpoints can be found in the swagger documentation.

See [external login state/S256 binding and rollout](docs/external-login.md) for
the companion AdminUI authentication changes and required server-first upgrade.

## Concurrent browser sessions

By default, the same user can sign in from multiple browsers or devices using the
Expand Down
44 changes: 44 additions & 0 deletions docs/external-login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# External login: state and S256 binding

Deploy this server before deploying the companion AdminUI changes in
`SenseNet/sn-client` branch `codex/auth-lifecycle-fixes`. Existing clients remain
compatible when they omit all binding fields. The new client requires a bound
callback and reports an authentication error against an older server.

The browser creates a fresh cryptographic `state` and RFC 7636 verifier for each
login, storing them in sessionStorage for that tab, repository, auth server and
callback. The `/Login` request adds `state`, `codeChallenge` (base64url SHA-256 of
the verifier) and `codeChallengeMethod=S256` to `RedirectUrl` and `CallbackUri`.
Partial or malformed binding is rejected. The callback must share the allowed
RedirectUrl's scheme, host and port.

Password, remembered login and MFA keep the original binding. Registration and
password recovery preserve the encoded login context. The successful callback
contains `auth_code` and `state`. The client validates and consumes its matching
pending transaction before posting `{ token, codeVerifier }` to
`/api/auth/convert-auth-token`.

The server verifies proof before atomically consuming a code. Missing/wrong proof
cannot redeem or burn a bound code. A proof-bearing request cannot redeem a
legacy unbound code. Bound MFA tokens cannot bypass proof through the direct
MFA login API. Session responses retain their existing accessToken/refreshToken
fields, and session rotation, parallel sessions and SingleSessionPerUser keep
their existing scope.

`AuthTokenExpiryMinutes` and `MultiFactorAuthExpiryMinutes` now expire in minutes.
Previously those two settings were inadvertently interpreted as days. Review
their configured values before rollout. Temporary codes and sessions still live
in one server process's memory.

Validation:

```powershell
dotnet test src/SenseNetAuth.sln --no-restore
dotnet publish src/sn-auth/SenseNetAuth.csproj -c Release --no-restore
```

Regression coverage includes real HTTP forms/callbacks/navigation, proof and
single-use races, MFA, legacy compatibility, minute expiry, and the existing
single/parallel-session cases. Complete the companion AdminUI's
`apps/sensenet/AUTHENTICATION-TESTING.md` matrix against the intended test servers
before promoting both changes.
34 changes: 34 additions & 0 deletions src/sn-auth/Models/AuthorizationCodeBinding.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.WebUtilities;
using SenseNetAuth.Infrastructure.Exceptions;
using SenseNetAuth.Models.Constants;

namespace SenseNetAuth.Models;

// Stored with the temporary code (including the MFA step), never with a session.
public sealed record AuthorizationCodeBinding(string State, string CodeChallenge, string CallbackUri)
{
public static AuthorizationCodeBinding? Create(string? state, string? challenge, string? method, string? callbackUri)
{
if (string.IsNullOrEmpty(state) && string.IsNullOrEmpty(challenge) && string.IsNullOrEmpty(method))
return null; // Existing clients may still use the unbound protocol during rollout.

if (string.IsNullOrEmpty(state) || state.Length > 512 ||
!Regex.IsMatch(state, @"\A[A-Za-z0-9_-]{32,512}\z") || method != "S256" ||
challenge == null || !Regex.IsMatch(challenge, @"\A[A-Za-z0-9_-]{43}\z"))
throw new BadRequestException(ResponseMessages.InvalidAuthToken);

return new AuthorizationCodeBinding(state, challenge, callbackUri ?? string.Empty);
}

public bool Matches(string? verifier)
{
if (verifier == null || !Regex.IsMatch(verifier, @"\A[A-Za-z0-9._~-]{43,128}\z"))
return false;

var challenge = WebEncoders.Base64UrlEncode(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));
return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(CodeChallenge), Encoding.ASCII.GetBytes(challenge));
}
}
4 changes: 4 additions & 0 deletions src/sn-auth/Models/LoginRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ public class LoginRequest
public string RememberMeToken { get; set; } = string.Empty;
public bool RememberMeRequested { get; set; }
public required string SiteUrl { get; set; }
public string State { get; set; } = string.Empty;
public string CodeChallenge { get; set; } = string.Empty;
public string CodeChallengeMethod { get; set; } = string.Empty;
public string CallbackUri { get; set; } = string.Empty;
}
2 changes: 2 additions & 0 deletions src/sn-auth/Models/LoginResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ public class LoginResponse
public string AccessToken { get; set; } = string.Empty;
public string RefreshToken { get; set; } = string.Empty;
public string AuthToken { get; set; } = string.Empty;
public string? State { get; set; }
public string? CallbackUri { get; set; }
public bool MultiFactorRequired { get; set; }
public string QrCodeSetupImageUrl { get; set; } = string.Empty;
public string ManualEntryKey { get; set; } = string.Empty;
Expand Down
1 change: 1 addition & 0 deletions src/sn-auth/Models/TokenRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
public class TokenRequest
{
public string Token { get; set; } = string.Empty;
public string? CodeVerifier { get; set; }
}
65 changes: 65 additions & 0 deletions src/sn-auth/Models/ViewModels/ExternalLoginFlow.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.WebUtilities;
using SenseNetAuth.Models;

namespace SenseNetAuth.Models.ViewModels;

public class ExternalLoginFlow
{
public string RedirectUrl { get; set; } = string.Empty;
public string CallbackUri { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public string CodeChallenge { get; set; } = string.Empty;
public string CodeChallengeMethod { get; set; } = string.Empty;

public string QueryString => Microsoft.AspNetCore.Http.QueryString.Create(new Dictionary<string, string?>
{
["redirectUrl"] = RedirectUrl, ["callbackUri"] = CallbackUri,
["state"] = State, ["codeChallenge"] = CodeChallenge, ["codeChallengeMethod"] = CodeChallengeMethod
}).ToString();

public static ExternalLoginFlow FromForm(IFormCollection form) => new()
{
RedirectUrl = form["RedirectUrl"].ToString(), CallbackUri = form["CallbackUri"].ToString(),
State = form["State"].ToString(), CodeChallenge = form["CodeChallenge"].ToString(),
CodeChallengeMethod = form["CodeChallengeMethod"].ToString()
};

public T CopyTo<T>(T model) where T : ExternalLoginFlow
{
model.RedirectUrl = RedirectUrl;
model.CallbackUri = CallbackUri;
model.State = State;
model.CodeChallenge = CodeChallenge;
model.CodeChallengeMethod = CodeChallengeMethod;
return model;
}

public bool IsValid(IEnumerable<string> allowedHosts)
{
if (!allowedHosts.Contains(RedirectUrl) || !TryCallback(CallbackUri, out _))
return false;
try { _ = AuthorizationCodeBinding.Create(State, CodeChallenge, CodeChallengeMethod, CallbackUri); }
catch { return false; }
return true;
}

private bool TryCallback(string callbackUri, out Uri? callback)
{
callback = null;
return Uri.TryCreate(RedirectUrl, UriKind.Absolute, out var site) &&
(site.Scheme == "https" || site.Scheme == "http") &&
Uri.TryCreate(site, callbackUri, out callback) &&
callback.Scheme == site.Scheme && callback.Host == site.Host && callback.Port == site.Port &&
string.IsNullOrEmpty(callback.UserInfo);
}

public string CallbackUrl(LoginResponse response)
{
// Bound MFA responses carry the callback that was validated before password authentication.
if (!TryCallback(response.CallbackUri ?? CallbackUri, out var callback))
throw new InvalidOperationException("Invalid login callback");
var query = new Dictionary<string, string?> { ["auth_code"] = response.AuthToken };
if (!string.IsNullOrEmpty(response.State)) query["state"] = response.State;
return QueryHelpers.AddQueryString(callback!.AbsoluteUri, query);
}
}
4 changes: 1 addition & 3 deletions src/sn-auth/Models/ViewModels/ForgottenPasswordViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
namespace SenseNetAuth.Models.ViewModels;

public class ForgottenPasswordViewModel
public class ForgottenPasswordViewModel : ExternalLoginFlow
{
public string RepositoryUrl { get; set; } = string.Empty;
public string RedirectUrl { get; set; } = string.Empty;
public string CallbackUri { get; set; } = string.Empty;
public string ErrorMessage { get; set; } = string.Empty;
public string SuccessMessage { get; set; } = string.Empty;
}
4 changes: 1 addition & 3 deletions src/sn-auth/Models/ViewModels/LoginViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
namespace SenseNetAuth.Models.ViewModels;

public class LoginViewModel
public class LoginViewModel : ExternalLoginFlow
{
public string RepositoryUrl { get; set; } = string.Empty;
public string RedirectUrl { get; set; } = string.Empty;
public string CallbackUri { get; set; } = string.Empty;
public bool IsRegistrationEnabled { get; set; }
public bool IsHostInvalid { get; set; }
public string ErrorMessage { get; set; } = string.Empty;
Expand Down
4 changes: 1 addition & 3 deletions src/sn-auth/Models/ViewModels/MultiFactorViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
namespace SenseNetAuth.Models.ViewModels;

public class MultiFactorViewModel
public class MultiFactorViewModel : ExternalLoginFlow
{
public string RepositoryUrl { get; set; } = string.Empty;
public string RedirectUrl { get; set; } = string.Empty;
public string CallbackUri { get; set; } = string.Empty;
public bool MultiFactorRequired { get; set; }
public string QrCodeSetupImageUrl { get; set; } = string.Empty;
public string ManualEntryKey { get; set; } = string.Empty;
Expand Down
4 changes: 1 addition & 3 deletions src/sn-auth/Models/ViewModels/PasswordRecoveryViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
namespace SenseNetAuth.Models.ViewModels;

public class PasswordRecoveryViewModel
public class PasswordRecoveryViewModel : ExternalLoginFlow
{
public string RepositoryUrl { get; set; } = string.Empty;
public string RedirectUrl { get; set; } = string.Empty;
public string CallbackUri { get; set; } = string.Empty;
public string ErrorMessage { get; set; } = string.Empty;
public string RecoveryToken { get; set; } = string.Empty;
}
4 changes: 1 addition & 3 deletions src/sn-auth/Models/ViewModels/RegistrationViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
namespace SenseNetAuth.Models.ViewModels;

public class RegistrationViewModel
public class RegistrationViewModel : ExternalLoginFlow
{
public string RepositoryUrl { get; set; } = string.Empty;
public string RedirectUrl { get; set; } = string.Empty;
public string CallbackUri { get; set; } = string.Empty;
public bool IsRegistrationEnabled { get; set; }
public bool IsHostInvalid { get; set; }
public string ErrorMessage { get; set; } = string.Empty;
Expand Down
25 changes: 21 additions & 4 deletions src/sn-auth/Services/AuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ public async Task<LoginResponse> MultiFactorLoginAsync(MultiFactorLoginRequest l
if (!userInfo.HasValue || userInfo.Value.SiteUrl != loginRequest.SiteUrl)
throw new BadRequestException(ResponseMessages.InvalidMultiFactorToken);

if (directLogin && userInfo.Value.AuthorizationCodeBinding != null)
throw new BadRequestException(ResponseMessages.InvalidMultiFactorToken);

if (!await _userService.ValidateTwoFactorCodeAsync(userInfo.Value.UserId, loginRequest.MultiFactorCode, cancel))
throw new BadRequestException(ResponseMessages.InvalidMultiFactorCode);

Expand All @@ -119,7 +122,9 @@ public async Task<LoginResponse> MultiFactorLoginAsync(MultiFactorLoginRequest l

return new LoginResponse
{
AuthToken = _authTokenProvider.CreateToken(consumed.UserId, consumed.SiteUrl)
AuthToken = _authTokenProvider.CreateToken(consumed),
State = consumed.AuthorizationCodeBinding?.State,
CallbackUri = consumed.AuthorizationCodeBinding?.CallbackUri
};
}
}
Expand Down Expand Up @@ -157,6 +162,7 @@ private LoginResponse CreateTokenPair(UserInfo userInfo)
{
lock (_tokenLock)
{
userInfo = userInfo with { AuthorizationCodeBinding = null };
if (string.IsNullOrEmpty(userInfo.SessionId))
userInfo = userInfo with { SessionId = Guid.NewGuid().ToString("N") };

Expand Down Expand Up @@ -242,6 +248,8 @@ public async Task PasswordRecoveryAsync(PasswordRecoveryRequest passwordRecovery

public async Task<LoginResponse> AuthenticateAsync(LoginRequest loginRequest, CancellationToken cancel)
{
var binding = AuthorizationCodeBinding.Create(loginRequest.State, loginRequest.CodeChallenge,
loginRequest.CodeChallengeMethod, loginRequest.CallbackUri);
int userId;
if (!string.IsNullOrEmpty(loginRequest.RememberMeToken))
{
Expand All @@ -260,7 +268,8 @@ public async Task<LoginResponse> AuthenticateAsync(LoginRequest loginRequest, Ca

var multiFactorInfo = await _userService.GetMultiFactorAuthenticationInfoAsync(userId, cancel);

var response = new LoginResponse();
var response = new LoginResponse { State = binding?.State, CallbackUri = binding?.CallbackUri };
var tokenUser = new UserInfo(userId, default, loginRequest.SiteUrl) { AuthorizationCodeBinding = binding };

if (loginRequest.RememberMeRequested && string.IsNullOrEmpty(loginRequest.RememberMeToken))
{
Expand All @@ -277,7 +286,7 @@ public async Task<LoginResponse> AuthenticateAsync(LoginRequest loginRequest, Ca
{
response.MultiFactorRequired = true;
lock (_tokenLock)
response.MultiFactorAuthToken = _multiFactorAuthTokenProvider.CreateToken(userId, loginRequest.SiteUrl);
response.MultiFactorAuthToken = _multiFactorAuthTokenProvider.CreateToken(tokenUser);

if (!multiFactorInfo.MultiFactorRegistered)
{
Expand All @@ -288,7 +297,7 @@ public async Task<LoginResponse> AuthenticateAsync(LoginRequest loginRequest, Ca
else
{
lock (_tokenLock)
response.AuthToken = _authTokenProvider.CreateToken(userId, loginRequest.SiteUrl);
response.AuthToken = _authTokenProvider.CreateToken(tokenUser);
}

return response;
Expand All @@ -298,6 +307,14 @@ public LoginResponse ConvertAuthToken(TokenRequest tokenRequest)
{
lock (_tokenLock)
{
var pending = _authTokenProvider.GetValidUserInfo(tokenRequest.Token)
?? throw new UnauthorizedException(ResponseMessages.InvalidAuthToken);
// Check proof before consuming: an invalid request cannot burn a legitimate code.
// A proof-bearing client must also reject legacy, unbound codes (downgrade protection).
if (pending.AuthorizationCodeBinding is { } binding
? !binding.Matches(tokenRequest.CodeVerifier)
: tokenRequest.CodeVerifier != null)
throw new UnauthorizedException(ResponseMessages.InvalidAuthToken);
var userInfo = _authTokenProvider.ConsumeToken(tokenRequest.Token)
?? throw new UnauthorizedException(ResponseMessages.InvalidAuthToken);
return CreateTokenPair(userInfo);
Expand Down
7 changes: 5 additions & 2 deletions src/sn-auth/TokenProviders/InMemory/AuthTokenProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ public AuthTokenProvider(IOptions<JwtSettings> options)
}

public override string CreateToken(int userId, string siteUrl)
=> CreateToken(new UserInfo(userId, default, siteUrl));

public override string CreateToken(UserInfo userInfo)
{
var expiration = new DateTimeOffset(DateTime.UtcNow.AddDays(_jwtSettings.AuthTokenExpiryMinutes));
var expiration = DateTimeOffset.UtcNow.AddMinutes(_jwtSettings.AuthTokenExpiryMinutes);
var token = GenerateUniqueToken(128);

StoreToken(token, new UserInfo(userId, expiration, siteUrl), _jwtSettings.SingleSessionPerUser);
StoreToken(token, userInfo with { Expiry = expiration }, _jwtSettings.SingleSessionPerUser);

return token;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
using Microsoft.Extensions.Options;
using SenseNet.Client;
using SenseNetAuth.Models.Options;
using System.Security.Cryptography;

namespace SenseNetAuth.TokenProviders.InMemory;

Expand All @@ -15,11 +13,14 @@ public MultiFactorAuthTokenProvider(IOptions<JwtSettings> jwtSettings)
}

public override string CreateToken(int userId, string siteUrl)
=> CreateToken(new UserInfo(userId, default, siteUrl));

public override string CreateToken(UserInfo userInfo)
{
var expiration = new DateTimeOffset(DateTime.UtcNow.AddDays(_jwtSettings.MultiFactorAuthExpiryMinutes));
var expiration = DateTimeOffset.UtcNow.AddMinutes(_jwtSettings.MultiFactorAuthExpiryMinutes);
var token = GenerateUniqueToken(128);

StoreToken(token, new UserInfo(userId, expiration, siteUrl), _jwtSettings.SingleSessionPerUser);
StoreToken(token, userInfo with { Expiry = expiration }, _jwtSettings.SingleSessionPerUser);

return token;
}
Expand Down
1 change: 1 addition & 0 deletions src/sn-auth/TokenProviders/UserInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
public record struct UserInfo(int UserId, DateTimeOffset Expiry, string SiteUrl)
{
public string SessionId { get; init; } = string.Empty;
public Models.AuthorizationCodeBinding? AuthorizationCodeBinding { get; init; }

public static implicit operator UserInfo((int UserId, DateTimeOffset Expiry, string siteUrl) value)
{
Expand Down
Loading
Loading