diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 39c4c0a7..51252ee1 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,5 +1,3 @@
-# 🚀 Pull Request
-
diff --git a/.github/codecov.yml b/.github/codecov.yml
index c52c4b6c..d27f31d0 100644
--- a/.github/codecov.yml
+++ b/.github/codecov.yml
@@ -5,9 +5,9 @@ coverage:
status:
project:
default:
- target: 70%
- threshold: 0%
+ target: 80%
+ threshold: 2%
patch:
default:
- target: 50%
- threshold: 0%
+ target: 80%
+ threshold: 5%
diff --git a/README.md b/README.md
index 9e7a8215..ac155657 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ UltimateAuth is an open-source auth framework with platform-level capabilities t
| Phase | Version | Scope | Status | Release Date |
| ----------------------- | ------------- | ----------------------------------------- | -------------- | ------------ |
| First Preview | 0.1.0-preview | "Stable" Preview Core | ✅ Completed | 07.04.2026 |
-| First Release* | 0.1.0 | Fully Documented & Quality Tested | ✅ Completed | 08.10.2026 |
+| First Release* | 0.1.0 | Documented & Quality Tested | ✅ Completed | 08.10.2026 |
| Product Expansion | 0.2.0 | Full Auth Modes | 🟡 In Progress | Q4 2026 |
| Security Expansion | 0.3.0 | MFA, Reauth, Rate Limiting | 🟡 In Progress | Q4 2026 |
| Infrastructure Expansion| 0.4.0 | Redis, Distributed Cache, Password Hasher | 🔜 Planned | Q1 2027 |
diff --git a/ROADMAP.md b/ROADMAP.md
index a35c79a6..323f459f 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,61 +1,10 @@
# UltimateAuth Roadmap
-This document outlines the planned development phases of UltimateAuth.
-Dates are targets and may evolve based on community feedback and real-world usage.
+> The project roadmap is actively maintained as a GitHub issue:
----
-
-## 🟣 Q1 2026 — Preview (v 0.1.0)
-The first public preview of UltimateAuth:
-- Core session-based auth engine
-- Secure opaque SessionId tokens
-- Basic login / logout / refresh and PKCE flows
-- Blazor & MAUI client foundations
-- Initial server abstractions and extension points
-- Early documentation and samples
+👉 https://github.com/CodeBeamOrg/UltimateAuth/issues/8
---
-## 🟣 Q2 2026 — Stable Feature Release
-Focus on stabilization, developer experience, and broader platform support:
-- Hardened session lifecycle
-- Expanded testing and validation
-- Improved client SDK flows
-- ASP.NET Core middleware polishing
-- More complete documentation
-- First public samples for real applications
-
----
-
-## 🟣 Q3 2026 — Version 1.0.0 (General Availability)
-UltimateAuth reaches production-readiness:
-- API surface finalized and locked
-- Full PKCE/OAuth-style flow support
-- Unified architecture across client & server
-- Performance improvements
-- Security review & hardening
-- Real-world feedback applied
-- Long-term support model established
-
----
-
-## 🟣 Q4 2026 — .NET 11 Alignment (v11.x.x)
-UltimateAuth aligns its versioning and platform features with .NET 11:
-- Framework-wide modernization updates
-- Future-proof API adjustments
-- Compatibility refinements
-
----
-
-## 🟣 Beyond 2026 — Long-Term Vision
-- Advanced OAuth integrations
-- External identity provider modules
-- Enhanced build-in security features (MFA, biometrics)
-- Extended session storage providers
-- Developer tooling & templates
-- Deep Blazor/Maui experience enhancements
-- Distributed session management options
-
----
+UltimateAuth is developed openly and shaped by community feedback.
-UltimateAuth is developed openly and shaped by community feedback.
Thank you for helping us build the next-generation authentication framework for .NET.
diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx
index 9bef746e..5f659632 100644
--- a/UltimateAuth.slnx
+++ b/UltimateAuth.slnx
@@ -26,6 +26,7 @@
+
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-shm b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-shm
index 86e2b19d..c4334748 100644
Binary files a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-shm and b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-shm differ
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-wal b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-wal
index 932093f3..05e590d5 100644
Binary files a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-wal and b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-wal differ
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthContextFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthContextFactory.cs
index 1dc892e9..284584fb 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthContextFactory.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthContextFactory.cs
@@ -1,8 +1,8 @@
-using CodeBeam.UltimateAuth.Core.Contracts;
+//using CodeBeam.UltimateAuth.Core.Contracts;
-namespace CodeBeam.UltimateAuth.Core.Abstractions;
+//namespace CodeBeam.UltimateAuth.Core.Abstractions;
-public interface IAuthContextFactory
-{
- AuthContext Create(DateTimeOffset? at = null);
-}
+//public interface IAuthContextFactory
+//{
+// AuthContext Create(DateTimeOffset? at = null);
+//}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Tenant/IUAuthMultiTenantOptionsAccessor.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Tenant/IUAuthMultiTenantOptionsAccessor.cs
new file mode 100644
index 00000000..8bb43da9
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Tenant/IUAuthMultiTenantOptionsAccessor.cs
@@ -0,0 +1,8 @@
+using CodeBeam.UltimateAuth.Core.Options;
+
+namespace CodeBeam.UltimateAuth.Core.Abstractions;
+
+public interface IUAuthMultiTenantOptionsAccessor
+{
+ UAuthMultiTenantOptions MultiTenant { get; }
+}
\ No newline at end of file
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IJwtValidator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IJwtValidator.cs
index 404422a8..57ec16f2 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IJwtValidator.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IJwtValidator.cs
@@ -1,12 +1,12 @@
-using CodeBeam.UltimateAuth.Core.Contracts;
+//using CodeBeam.UltimateAuth.Core.Contracts;
-namespace CodeBeam.UltimateAuth.Core.Abstractions;
+//namespace CodeBeam.UltimateAuth.Core.Abstractions;
-///
-/// Validates access tokens (JWT or opaque) and resolves
-/// the authenticated user context.
-///
-public interface IJwtValidator
-{
- Task> ValidateAsync(string token, CancellationToken ct = default);
-}
+/////
+///// Validates access tokens (JWT or opaque) and resolves
+///// the authenticated user context.
+/////
+//public interface IJwtValidator
+//{
+// Task> ValidateAsync(string token, CancellationToken ct = default);
+//}
diff --git a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs
index b346c2c9..284fd414 100644
--- a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs
+++ b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs
@@ -9,3 +9,4 @@
[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore")]
[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")]
[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Integration")]
+[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Integration.EfCore")]
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs
index a3cea858..332e0e34 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs
@@ -3,15 +3,12 @@
public enum RefreshTokenPersistence
{
///
- /// Refresh token store'a yazılır.
- /// Login, first-issue gibi normal akışlar için.
+ /// Refresh token persists to the store.
///
Persist = 0,
///
- /// Refresh token store'a yazılmaz.
- /// Rotation gibi özel akışlarda,
- /// caller tarafından kontrol edilir.
+ /// Refresh token does not persist to the store.
///
DoNotPersist = 10
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs
index 7a617737..bc4d9c9e 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs
@@ -2,10 +2,18 @@
namespace CodeBeam.UltimateAuth.Core.Contracts;
+// TODO: Validation protocol review after SemiHybrid/PureJwt: Re-evaluate exposure and necessity of
+// ChainId, RootId, and BoundDeviceId in AuthValidationResult once all AuthModes have concrete validation semantics.
+// Prefer the smallest common public contract and keep security lineage internal where possible.
public sealed record AuthValidationResult
{
public required SessionState State { get; init; }
public AuthStateSnapshot? Snapshot { get; init; }
+ public Guid? ChainId { get; init; }
+
+ public Guid? RootId { get; init; }
+
+ public string? BoundDeviceId { get; init; }
public bool IsValid => State == SessionState.Active;
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotInfo.cs
deleted file mode 100644
index 79654c8c..00000000
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotInfo.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts;
-
-public sealed class AuthSnapshotInfo
-{
- public IdentityInfo? Identity { get; set; }
-
- public ClaimsInfo? Claims { get; set; }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsInfo.cs
deleted file mode 100644
index 005895b3..00000000
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsInfo.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts;
-
-public sealed class ClaimsInfo
-{
- public Dictionary Claims { get; set; } = new();
-
- public string[] Roles { get; set; } = Array.Empty();
-
- public string[] Permissions { get; set; } = Array.Empty();
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/IdentityInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/IdentityInfo.cs
deleted file mode 100644
index c7488e64..00000000
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/IdentityInfo.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts;
-
-public sealed class IdentityInfo
-{
- public string Tenant { get; set; } = default!;
-
- public string? UserKey { get; set; }
-
- public DateTimeOffset? AuthenticatedAt { get; set; }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationInfo.cs
deleted file mode 100644
index c4519080..00000000
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationInfo.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts;
-
-public sealed class SessionValidationInfo
-{
- public int State { get; set; } = default!;
-
- public bool IsValid { get; set; }
-
- public AuthSnapshotInfo? Snapshot { get; set; }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs
index 42c51971..0cf300f6 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs
@@ -1,35 +1,35 @@
-using CodeBeam.UltimateAuth.Core.Domain;
+//using CodeBeam.UltimateAuth.Core.Domain;
-namespace CodeBeam.UltimateAuth.Core.Contracts;
+//namespace CodeBeam.UltimateAuth.Core.Contracts;
-public sealed record ResolvedRefreshSession
-{
- public bool IsValid { get; init; }
- public bool IsReuseDetected { get; init; }
+//public sealed record ResolvedRefreshSession
+//{
+// public bool IsValid { get; init; }
+// public bool IsReuseDetected { get; init; }
- public UAuthSession? Session { get; init; }
- public UAuthSessionChain? Chain { get; init; }
+// public UAuthSession? Session { get; init; }
+// public UAuthSessionChain? Chain { get; init; }
- private ResolvedRefreshSession() { }
+// private ResolvedRefreshSession() { }
- public static ResolvedRefreshSession Invalid()
- => new()
- {
- IsValid = false
- };
+// public static ResolvedRefreshSession Invalid()
+// => new()
+// {
+// IsValid = false
+// };
- public static ResolvedRefreshSession Reused()
- => new()
- {
- IsValid = false,
- IsReuseDetected = true
- };
+// public static ResolvedRefreshSession Reused()
+// => new()
+// {
+// IsValid = false,
+// IsReuseDetected = true
+// };
- public static ResolvedRefreshSession Valid(UAuthSession session, UAuthSessionChain chain)
- => new()
- {
- IsValid = true,
- Session = session,
- Chain = chain
- };
-}
+// public static ResolvedRefreshSession Valid(UAuthSession session, UAuthSessionChain chain)
+// => new()
+// {
+// IsValid = true,
+// Session = session,
+// Chain = chain
+// };
+//}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs
index be3a120c..368e4a4b 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs
@@ -1,19 +1,19 @@
-using CodeBeam.UltimateAuth.Core.Domain;
+//using CodeBeam.UltimateAuth.Core.Domain;
-namespace CodeBeam.UltimateAuth.Core.Contracts;
+//namespace CodeBeam.UltimateAuth.Core.Contracts;
-public sealed record PrimaryToken
-{
- public PrimaryTokenKind Kind { get; }
- public string Value { get; }
+//public sealed record PrimaryToken
+//{
+// public PrimaryTokenKind Kind { get; }
+// public string Value { get; }
- private PrimaryToken(PrimaryTokenKind kind, string value)
- {
- Kind = kind;
- Value = value;
- }
+// private PrimaryToken(PrimaryTokenKind kind, string value)
+// {
+// Kind = kind;
+// Value = value;
+// }
- public static PrimaryToken FromSession(AuthSessionId sessionId) => new(PrimaryTokenKind.Session, sessionId.ToString());
+// public static PrimaryToken FromSession(AuthSessionId sessionId) => new(PrimaryTokenKind.Session, sessionId.ToString());
- public static PrimaryToken FromAccessToken(AccessToken token) => new(PrimaryTokenKind.AccessToken, token.Token);
-}
+// public static PrimaryToken FromAccessToken(AccessToken token) => new(PrimaryTokenKind.AccessToken, token.Token);
+//}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs
deleted file mode 100644
index 2796946b..00000000
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-using CodeBeam.UltimateAuth.Core.MultiTenancy;
-
-namespace CodeBeam.UltimateAuth.Core.Contracts;
-
-public sealed record TokenRefreshContext
-{
- public TenantKey Tenant { get; init; }
-
- public string RefreshToken { get; init; } = default!;
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs
index 372fd4fd..c77b6a71 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs
@@ -1,68 +1,68 @@
-using CodeBeam.UltimateAuth.Core.Domain;
-using CodeBeam.UltimateAuth.Core.MultiTenancy;
-using System.Security.Claims;
+//using CodeBeam.UltimateAuth.Core.Domain;
+//using CodeBeam.UltimateAuth.Core.MultiTenancy;
+//using System.Security.Claims;
-namespace CodeBeam.UltimateAuth.Core.Contracts;
+//namespace CodeBeam.UltimateAuth.Core.Contracts;
-public sealed record TokenValidationResult
-{
- public bool IsValid { get; init; }
- public TokenFormat Format { get; init; }
- public TenantKey? Tenant { get; init; }
- public TUserId? UserId { get; init; }
- public AuthSessionId? SessionId { get; init; }
- public IReadOnlyCollection Claims { get; init; } = Array.Empty();
- public TokenInvalidReason? InvalidReason { get; init; }
- public DateTimeOffset? ExpiresAt { get; set; }
+//public sealed record TokenValidationResult
+//{
+// public bool IsValid { get; init; }
+// public TokenFormat Format { get; init; }
+// public TenantKey? Tenant { get; init; }
+// public TUserId? UserId { get; init; }
+// public AuthSessionId? SessionId { get; init; }
+// public IReadOnlyCollection Claims { get; init; } = Array.Empty();
+// public TokenInvalidReason? InvalidReason { get; init; }
+// public DateTimeOffset? ExpiresAt { get; set; }
- private TokenValidationResult(
- bool isValid,
- TokenFormat format,
- TenantKey? tenant,
- TUserId? userId,
- AuthSessionId? sessionId,
- IReadOnlyCollection? claims,
- TokenInvalidReason? invalidReason,
- DateTimeOffset? expiresAt
- )
- {
- IsValid = isValid;
- Format = format;
- Tenant = tenant;
- UserId = userId;
- SessionId = sessionId;
- Claims = claims ?? Array.Empty();
- InvalidReason = invalidReason;
- ExpiresAt = expiresAt;
- }
+// private TokenValidationResult(
+// bool isValid,
+// TokenFormat format,
+// TenantKey? tenant,
+// TUserId? userId,
+// AuthSessionId? sessionId,
+// IReadOnlyCollection? claims,
+// TokenInvalidReason? invalidReason,
+// DateTimeOffset? expiresAt
+// )
+// {
+// IsValid = isValid;
+// Format = format;
+// Tenant = tenant;
+// UserId = userId;
+// SessionId = sessionId;
+// Claims = claims ?? Array.Empty();
+// InvalidReason = invalidReason;
+// ExpiresAt = expiresAt;
+// }
- public static TokenValidationResult Valid(
- TokenFormat format,
- TenantKey tenant,
- TUserId userId,
- AuthSessionId? sessionId,
- IReadOnlyCollection claims,
- DateTimeOffset? expiresAt)
- => new(
- isValid: true,
- format,
- tenant,
- userId,
- sessionId,
- claims,
- invalidReason: null,
- expiresAt
- );
+// public static TokenValidationResult Valid(
+// TokenFormat format,
+// TenantKey tenant,
+// TUserId userId,
+// AuthSessionId? sessionId,
+// IReadOnlyCollection claims,
+// DateTimeOffset? expiresAt)
+// => new(
+// isValid: true,
+// format,
+// tenant,
+// userId,
+// sessionId,
+// claims,
+// invalidReason: null,
+// expiresAt
+// );
- public static TokenValidationResult Invalid(TokenFormat format, TokenInvalidReason reason)
- => new(
- isValid: false,
- format: format,
- tenant: null,
- userId: default,
- sessionId: null,
- claims: null,
- invalidReason: reason,
- expiresAt: null
- );
-}
+// public static TokenValidationResult Invalid(TokenFormat format, TokenInvalidReason reason)
+// => new(
+// isValid: false,
+// format: format,
+// tenant: null,
+// userId: default,
+// sessionId: null,
+// claims: null,
+// invalidReason: reason,
+// expiresAt: null
+// );
+//}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs
index a105c336..78bd7e1c 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs
@@ -1,25 +1,25 @@
-using CodeBeam.UltimateAuth.Core.Domain;
+//using CodeBeam.UltimateAuth.Core.Domain;
-namespace CodeBeam.UltimateAuth.Core.Contracts;
+//namespace CodeBeam.UltimateAuth.Core.Contracts;
-public sealed class UserAuthenticationResult
-{
- public bool Succeeded { get; init; }
+//public sealed class UserAuthenticationResult
+//{
+// public bool Succeeded { get; init; }
- public TUserId? UserId { get; init; }
+// public TUserId? UserId { get; init; }
- public ClaimsSnapshot? Claims { get; init; }
+// public ClaimsSnapshot? Claims { get; init; }
- public bool RequiresMfa { get; init; }
+// public bool RequiresMfa { get; init; }
- public static UserAuthenticationResult Fail() => new() { Succeeded = false };
+// public static UserAuthenticationResult Fail() => new() { Succeeded = false };
- public static UserAuthenticationResult Success(TUserId userId, ClaimsSnapshot claims, bool requiresMfa = false)
- => new()
- {
- Succeeded = true,
- UserId = userId,
- Claims = claims,
- RequiresMfa = requiresMfa
- };
-}
+// public static UserAuthenticationResult Success(TUserId userId, ClaimsSnapshot claims, bool requiresMfa = false)
+// => new()
+// {
+// Succeeded = true,
+// UserId = userId,
+// Claims = claims,
+// RequiresMfa = requiresMfa
+// };
+//}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs
index 5a20021b..b7f9aa51 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs
@@ -1,13 +1,13 @@
-using CodeBeam.UltimateAuth.Core.Domain;
+//using CodeBeam.UltimateAuth.Core.Domain;
-namespace CodeBeam.UltimateAuth.Core.Contracts;
+//namespace CodeBeam.UltimateAuth.Core.Contracts;
-public sealed class UserContext
-{
- public TUserId? UserId { get; init; }
- public IAuthSubject? User { get; init; }
+//public sealed class UserContext
+//{
+// public TUserId? UserId { get; init; }
+// public IAuthSubject? User { get; init; }
- public bool IsAuthenticated => UserId is not null;
+// public bool IsAuthenticated => UserId is not null;
- public static UserContext Anonymous() => new();
-}
+// public static UserContext Anonymous() => new();
+//}
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs
index f46ced1f..bb4458f2 100644
--- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs
@@ -1,92 +1,122 @@
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;
-using CodeBeam.UltimateAuth.Core.MultiTenancy;
namespace CodeBeam.UltimateAuth.Core.Infrastructure;
public static class SessionValidationMapper
{
- public static SessionValidationResult ToDomain(SessionValidationInfo dto)
+ public static SessionValidationResult ToDomain(AuthValidationResult dto, AuthSessionId sessionId)
{
- var state = (SessionState)dto.State;
+ ArgumentNullException.ThrowIfNull(dto);
- if (!dto.IsValid || dto.Snapshot?.Identity is null)
+ if (!dto.IsValid)
{
- return SessionValidationResult.Invalid(state);
+ return SessionValidationResult.Invalid(
+ dto.State,
+ sessionId: sessionId,
+ chainId: TryParseChainId(dto.ChainId),
+ rootId: TryParseRootId(dto.RootId),
+ boundDeviceId: TryParseDeviceId(dto.BoundDeviceId));
}
- var tenant = TenantKey.FromInternal(dto.Snapshot.Identity.Tenant);
+ //
+ // Active is a stronger contract than merely receiving
+ // a successful HTTP response. All required security
+ // lineage and identity data must be present.
+ //
- UserKey? userKey = string.IsNullOrWhiteSpace(dto.Snapshot.Identity.UserKey)
- ? null
- : UserKey.Parse(dto.Snapshot.Identity.UserKey, null);
-
- ClaimsSnapshot claims;
-
- if (dto.Snapshot.Claims is null)
+ if (dto.Snapshot?.Identity is null)
{
- claims = ClaimsSnapshot.Empty;
+ return SessionValidationResult.Invalid(
+ SessionState.Invalid,
+ sessionId: sessionId);
}
- else
+
+ if (dto.ChainId is not Guid chainGuid ||
+ chainGuid == Guid.Empty)
{
- var builder = ClaimsSnapshot.Create();
+ return SessionValidationResult.Invalid(
+ SessionState.Invalid,
+ sessionId: sessionId);
+ }
- foreach (var (type, values) in dto.Snapshot.Claims.Claims)
- {
- builder.AddMany(type, values);
- }
+ var chainId =
+ SessionChainId.From(chainGuid);
- foreach (var role in dto.Snapshot.Claims.Roles)
- {
- builder.AddRole(role);
- }
+ if (dto.RootId is not Guid rootGuid ||
+ rootGuid == Guid.Empty)
+ {
+ return SessionValidationResult.Invalid(
+ SessionState.Invalid,
+ sessionId: sessionId,
+ chainId: chainId);
+ }
- foreach (var permission in dto.Snapshot.Claims.Permissions)
- {
- builder.AddPermission(permission);
- }
+ var rootId =
+ SessionRootId.From(rootGuid);
- claims = builder.Build();
- }
-
- AuthSessionId.TryCreate("temp", out AuthSessionId tempSessionId);
+ var identity =
+ dto.Snapshot.Identity;
+
+ var boundDeviceId =
+ TryParseDeviceId(dto.BoundDeviceId);
return SessionValidationResult.Active(
- tenant,
- userKey,
- tempSessionId, // TODO: This is TEMP add real
- SessionChainId.New(), // TEMP
- SessionRootId.New(), // TEMP
- claims,
- dto.Snapshot.Identity.AuthenticatedAt ?? DateTimeOffset.UtcNow,
- null
- );
+ tenant: identity.Tenant,
+ userKey: identity.UserKey,
+ sessionId: sessionId,
+ chainId: chainId,
+ rootId: rootId,
+ claims: dto.Snapshot.Claims,
+ authenticatedAt:
+ identity.AuthenticatedAt
+ ?? DateTimeOffset.UtcNow,
+ boundDeviceId: boundDeviceId);
}
public static SessionSecurityContext? ToSecurityContext(SessionValidationResult result)
{
- if (!result.IsValid)
- {
- if (result?.SessionId is null)
- return null;
-
- return new SessionSecurityContext
- {
- SessionId = result.SessionId.Value,
- State = result.State,
- ChainId = result.ChainId,
- UserKey = result.UserKey,
- BoundDeviceId = result.BoundDeviceId
- };
- }
+ if (result.SessionId is null)
+ return null;
return new SessionSecurityContext
{
- SessionId = result.SessionId!.Value,
- State = SessionState.Active,
+ SessionId = result.SessionId.Value,
+ State = result.State,
ChainId = result.ChainId,
UserKey = result.UserKey,
BoundDeviceId = result.BoundDeviceId
};
}
+
+ private static SessionChainId? TryParseChainId(Guid? value)
+ {
+ if (value is not Guid guid ||
+ guid == Guid.Empty)
+ {
+ return null;
+ }
+
+ return SessionChainId.From(guid);
+ }
+
+ private static SessionRootId? TryParseRootId(Guid? value)
+ {
+ if (value is not Guid guid ||
+ guid == Guid.Empty)
+ {
+ return null;
+ }
+
+ return SessionRootId.From(guid);
+ }
+
+ private static DeviceId? TryParseDeviceId(string? value)
+ {
+ return DeviceId.TryCreate(
+ value,
+ out var id)
+ ? id
+ : null;
+ }
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthContextFactory.cs
index 948cfc8f..2a554a29 100644
--- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthContextFactory.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthContextFactory.cs
@@ -1,23 +1,23 @@
-using CodeBeam.UltimateAuth.Core.Abstractions;
-using CodeBeam.UltimateAuth.Core.Contracts;
-using CodeBeam.UltimateAuth.Server.Extensions;
+//using CodeBeam.UltimateAuth.Core.Abstractions;
+//using CodeBeam.UltimateAuth.Core.Contracts;
+//using CodeBeam.UltimateAuth.Server.Extensions;
-namespace CodeBeam.UltimateAuth.Server.Auth;
+//namespace CodeBeam.UltimateAuth.Server.Auth;
-internal sealed class AuthContextFactory : IAuthContextFactory
-{
- private readonly IAuthFlowContextAccessor _flow;
- private readonly IClock _clock;
+//internal sealed class AuthContextFactory : IAuthContextFactory
+//{
+// private readonly IAuthFlowContextAccessor _flow;
+// private readonly IClock _clock;
- public AuthContextFactory(IAuthFlowContextAccessor flow, IClock clock)
- {
- _flow = flow;
- _clock = clock;
- }
+// public AuthContextFactory(IAuthFlowContextAccessor flow, IClock clock)
+// {
+// _flow = flow;
+// _clock = clock;
+// }
- public AuthContext Create(DateTimeOffset? at = null)
- {
- var flow = _flow.Current;
- return flow.ToAuthContext(at ?? _clock.UtcNow);
- }
-}
+// public AuthContext Create(DateTimeOffset? at = null)
+// {
+// var flow = _flow.Current;
+// return flow.ToAuthContext(at ?? _clock.UtcNow);
+// }
+//}
diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLogoutRedirectResponse.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLogoutRedirectResponse.cs
deleted file mode 100644
index f042e790..00000000
--- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLogoutRedirectResponse.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace CodeBeam.UltimateAuth.Server.Auth;
-
-public sealed record EffectiveLogoutRedirectResponse
-(
- bool RedirectEnabled,
- string RedirectPath,
- bool AllowReturnUrlOverride
-);
diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs
index cb630b62..a449b3dc 100644
--- a/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs
@@ -3,8 +3,6 @@
using CodeBeam.UltimateAuth.Core.Errors;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
using CodeBeam.UltimateAuth.Core.Security;
-using CodeBeam.UltimateAuth.Server.Options;
-using Microsoft.Extensions.Options;
namespace CodeBeam.UltimateAuth.Server.Security;
diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs
index 302b4327..4774e393 100644
--- a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs
@@ -34,12 +34,12 @@ public async Task ExecuteAsync(AccessContext context, IAccessCommand command, Ca
var policies = _policyProvider.GetPolicies(context);
var decision = _authority.Decide(context, policies);
- if (!decision.IsAllowed)
- throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied");
-
if (decision.RequiresReauthentication)
throw new InvalidOperationException("Requires reauthentication.");
+ if (decision.IsDenied)
+ throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied");
+
await command.ExecuteAsync(ct);
}
@@ -52,12 +52,12 @@ public async Task ExecuteAsync(AccessContext context, IAccessC
var policies = _policyProvider.GetPolicies(context);
var decision = _authority.Decide(context, policies);
- if (!decision.IsAllowed)
- throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied");
-
if (decision.RequiresReauthentication)
throw new InvalidOperationException("Requires reauthentication.");
+ if (decision.IsDenied)
+ throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied");
+
return await command.ExecuteAsync(ct);
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/SessionRefreshResult.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/SessionRefreshResult.cs
deleted file mode 100644
index e0d6fc42..00000000
--- a/src/CodeBeam.UltimateAuth.Server/Contracts/SessionRefreshResult.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-namespace CodeBeam.UltimateAuth.Server.Contracts;
-
-public sealed class SessionRefreshResult
-{
- public bool Succeeded { get; }
- public string? NewSessionId { get; }
-
- private SessionRefreshResult(bool succeeded, string? newSessionId)
- {
- Succeeded = succeeded;
- NewSessionId = newSessionId;
- }
-
- public static SessionRefreshResult Success(string? newSessionId = null) => new(true, newSessionId);
- public static SessionRefreshResult Failed() => new(false, null);
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs
index 386388b0..3dfa860d 100644
--- a/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs
@@ -1,10 +1,10 @@
-namespace CodeBeam.UltimateAuth.Server.Diagnostics;
+//namespace CodeBeam.UltimateAuth.Server.Diagnostics;
-public sealed record UAuthDiagnostic(string code, string message, UAuthDiagnosticSeverity severity);
+//public sealed record UAuthDiagnostic(string code, string message, UAuthDiagnosticSeverity severity);
-public enum UAuthDiagnosticSeverity
-{
- Info = 0,
- Warning = 10,
- Error = 20
-}
+//public enum UAuthDiagnosticSeverity
+//{
+// Info = 0,
+// Warning = 10,
+// Error = 20
+//}
diff --git a/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthStartupDiagnostics.cs b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthStartupDiagnostics.cs
index c43e7083..ae248b2f 100644
--- a/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthStartupDiagnostics.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthStartupDiagnostics.cs
@@ -1,57 +1,57 @@
-using CodeBeam.UltimateAuth.Server.Options;
-using Microsoft.AspNetCore.Http;
-
-namespace CodeBeam.UltimateAuth.Server.Diagnostics;
-
-internal static class UAuthStartupDiagnostics
-{
- public static IEnumerable Analyze(UAuthServerOptions options)
- {
- foreach (var d in AnalyzeCookies(options))
- yield return d;
- }
-
- private static IEnumerable AnalyzeCookies(UAuthServerOptions options)
- {
- if (options.HubDeploymentMode != UAuthHubDeploymentMode.External)
- yield break;
-
- var session = options.Cookie.Session;
-
- if (session.SameSite == SameSiteMode.None &&
- session.SecurePolicy != CookieSecurePolicy.Always)
- {
- yield return new UAuthDiagnostic(
- code: "UAUTH001",
- message:
- "Session cookie uses SameSite=None without Secure in External deployment. " +
- "This is insecure and may expose authentication to network attackers.",
- severity: UAuthDiagnosticSeverity.Error);
- }
-
- var refresh = options.Cookie.RefreshToken;
-
- if (refresh.SameSite == SameSiteMode.None &&
- refresh.SecurePolicy != CookieSecurePolicy.Always)
- {
- yield return new UAuthDiagnostic(
- code: "UAUTH002",
- message:
- "Refresh token cookie uses SameSite=None without Secure in External deployment. " +
- "This is a critical security risk and MUST NOT be used outside development.",
- severity: UAuthDiagnosticSeverity.Error);
- }
-
- // TODO: Think again with MAUI.
- if (!refresh.HttpOnly)
- {
- yield return new UAuthDiagnostic(
- code: "UAUTH003",
- message:
- "Refresh token cookie is not HttpOnly. This allows JavaScript access and " +
- "significantly increases the impact of XSS vulnerabilities.",
- severity: UAuthDiagnosticSeverity.Warning);
- }
- }
-}
+//using CodeBeam.UltimateAuth.Server.Options;
+//using Microsoft.AspNetCore.Http;
+
+//namespace CodeBeam.UltimateAuth.Server.Diagnostics;
+
+//internal static class UAuthStartupDiagnostics
+//{
+// public static IEnumerable Analyze(UAuthServerOptions options)
+// {
+// foreach (var d in AnalyzeCookies(options))
+// yield return d;
+// }
+
+// private static IEnumerable AnalyzeCookies(UAuthServerOptions options)
+// {
+// if (options.HubDeploymentMode != UAuthHubDeploymentMode.External)
+// yield break;
+
+// var session = options.Cookie.Session;
+
+// if (session.SameSite == SameSiteMode.None &&
+// session.SecurePolicy != CookieSecurePolicy.Always)
+// {
+// yield return new UAuthDiagnostic(
+// code: "UAUTH001",
+// message:
+// "Session cookie uses SameSite=None without Secure in External deployment. " +
+// "This is insecure and may expose authentication to network attackers.",
+// severity: UAuthDiagnosticSeverity.Error);
+// }
+
+// var refresh = options.Cookie.RefreshToken;
+
+// if (refresh.SameSite == SameSiteMode.None &&
+// refresh.SecurePolicy != CookieSecurePolicy.Always)
+// {
+// yield return new UAuthDiagnostic(
+// code: "UAUTH002",
+// message:
+// "Refresh token cookie uses SameSite=None without Secure in External deployment. " +
+// "This is a critical security risk and MUST NOT be used outside development.",
+// severity: UAuthDiagnosticSeverity.Error);
+// }
+
+// // TODO: Think again with MAUI.
+// if (!refresh.HttpOnly)
+// {
+// yield return new UAuthDiagnostic(
+// code: "UAUTH003",
+// message:
+// "Refresh token cookie is not HttpOnly. This allows JavaScript access and " +
+// "significantly increases the impact of XSS vulnerabilities.",
+// severity: UAuthDiagnosticSeverity.Warning);
+// }
+// }
+//}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs
index 0270c215..768c4013 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs
@@ -104,15 +104,13 @@ public async Task TryCompleteAsync(HttpContext ctx)
});
}
- var validation = _validator.Validate(
- artifact,
- request.CodeVerifier,
- new PkceContextSnapshot(
- clientProfile: artifact.Context.ClientProfile,
- tenant: artifact.Context.Tenant,
- redirectUri: artifact.Context.RedirectUri,
- device: artifact.Context.Device),
- _clock.UtcNow);
+ var completionContext = new PkceContextSnapshot(
+ clientProfile: authContext.ClientProfile,
+ tenant: authContext.Tenant,
+ redirectUri: request.ReturnUrl,
+ device: authContext.Device);
+
+ var validation = _validator.Validate(artifact, request.CodeVerifier, completionContext, _clock.UtcNow);
if (!validation.Success)
{
@@ -175,7 +173,9 @@ public async Task CompleteAsync(HttpContext ctx)
AuthorizationCode = request.AuthorizationCode!,
CodeVerifier = request.CodeVerifier!,
Identifier = request.Identifier,
- Secret = request.Secret
+ Secret = request.Secret,
+ ReturnUrl = request.ReturnUrl,
+ HubSessionId = request.HubSessionId
},
ctx.RequestAborted);
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs
index b0875a33..bbe6fc31 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs
@@ -40,7 +40,7 @@ public async Task ValidateAsync(HttpContext context, CancellationToken
return Results.Json(
new AuthValidationResult
{
- State = SessionState.NotFound
+ State = SessionState.NotFound,
},
statusCode: StatusCodes.Status401Unauthorized
);
@@ -53,7 +53,7 @@ public async Task ValidateAsync(HttpContext context, CancellationToken
return Results.Json(
new AuthValidationResult
{
- State = SessionState.Invalid
+ State = SessionState.Invalid,
},
statusCode: StatusCodes.Status401Unauthorized
);
@@ -71,12 +71,26 @@ public async Task ValidateAsync(HttpContext context, CancellationToken
},
ct);
- if (result.UserKey is not UserKey userKey)
+ if (!result.IsValid)
+ {
+ return Results.Ok(new AuthValidationResult
+ {
+ State = result.State,
+ ChainId = result.ChainId?.Value,
+ RootId = result.RootId?.Value,
+ BoundDeviceId = result.BoundDeviceId?.Value
+ });
+ }
+
+ if (result.UserKey is not UserKey)
{
return Results.Json(
new AuthValidationResult
{
- State = SessionState.Invalid
+ State = SessionState.Invalid,
+ ChainId = result.ChainId?.Value,
+ RootId = result.RootId?.Value,
+ BoundDeviceId = result.BoundDeviceId?.Value
},
statusCode: StatusCodes.Status401Unauthorized
);
@@ -84,9 +98,27 @@ public async Task ValidateAsync(HttpContext context, CancellationToken
var snapshot = await _snapshotFactory.CreateAsync(result, ct);
+ if (snapshot is null)
+ {
+ return Results.Json(
+ new AuthValidationResult
+ {
+ State = SessionState.Invalid,
+ ChainId = result.ChainId?.Value,
+ RootId = result.RootId?.Value,
+ BoundDeviceId = result.BoundDeviceId?.Value
+ },
+ statusCode:
+ StatusCodes.Status401Unauthorized);
+ }
+
return Results.Ok(new AuthValidationResult
{
- State = result.IsValid ? SessionState.Active : result.State,
+ State = SessionState.Active,
+ ChainId = result.ChainId?.Value,
+ RootId = result.RootId?.Value,
+ BoundDeviceId = result.BoundDeviceId?.Value,
+
Snapshot = snapshot
});
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFailureReasonExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFailureReasonExtensions.cs
deleted file mode 100644
index d4ae11c5..00000000
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFailureReasonExtensions.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Domain;
-
-namespace CodeBeam.UltimateAuth.Server.Extensions;
-
-public static class AuthFailureReasonExtensions
-{
- public static string ToDefaultCode(this AuthFailureReason reason)
- => reason switch
- {
- AuthFailureReason.InvalidCredentials => "invalid_credentials",
- AuthFailureReason.LockedOut => "locked",
- AuthFailureReason.RequiresMfa => "mfa_required",
- AuthFailureReason.SessionExpired => "session_expired",
- AuthFailureReason.SessionRevoked => "session_revoked",
- AuthFailureReason.TenantDisabled => "tenant_disabled",
- AuthFailureReason.Unauthorized => "unauthorized",
- AuthFailureReason.ReauthenticationRequired => "reauthentication_required",
- _ => "failed"
- };
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
index 95b36ba5..690e5e98 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
@@ -133,6 +133,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
services.AddSingleton, UAuthServerMultiTenantOptionsValidator>();
services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>();
services.AddSingleton, UAuthServerSessionResolutionOptionsValidator>();
+ services.AddScoped();
services.TryAddEnumerable(ServiceDescriptor.Singleton());
services.TryAddEnumerable(ServiceDescriptor.Singleton());
@@ -206,7 +207,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
services.TryAddScoped();
services.TryAddScoped();
- services.TryAddScoped();
+ //services.TryAddScoped();
services.TryAddScoped();
services.TryAddScoped();
services.TryAddScoped();
@@ -415,7 +416,7 @@ private static IServiceCollection AddUltimateAuthResourceInternal(this IServiceC
services.AddScoped();
services.AddScoped, ResourceUserAccessor>();
- services.AddScoped();
+ //services.AddScoped();
services.AddScoped();
// Server & Resource API Shared
@@ -461,6 +462,8 @@ private static IServiceCollection AddUltimateAuthResourceInternal(this IServiceC
};
});
+ services.AddScoped();
+
// ASP.NET Core Integration
services.AddHttpContextAccessor();
services.AddAuthentication(options =>
diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs
index ac788f61..b70bddae 100644
--- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs
@@ -1,4 +1,5 @@
-using CodeBeam.UltimateAuth.Core.Infrastructure;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Infrastructure;
using System.Security.Cryptography;
using System.Text;
@@ -25,17 +26,28 @@ public PkceValidationResult Validate(PkceAuthorizationArtifact artifact, string
private static bool IsContextValid(PkceContextSnapshot original, PkceContextSnapshot completion)
{
- if (!original.ClientProfile.Equals(completion.ClientProfile))
- return false;
-
+ // Tenant is part of the server-side security boundary and must
+ // remain stable throughout the PKCE transaction.
if (!string.Equals(original.Tenant, completion.Tenant, StringComparison.Ordinal))
return false;
- if (!string.Equals(original.RedirectUri, completion.RedirectUri, StringComparison.Ordinal))
- return false;
+ // TODO: Bind the effective client profile rather than the physical
+ // completion request profile. In Hub flows the artifact may represent
+ // BlazorWasm while the completion request is executed by UAuthHub.
+ //if (!original.ClientProfile.Equals(completion.ClientProfile))
+ // return false;
- if (!Equals(original.Device, completion.Device))
- return false;
+ // TODO: Add protocol-level redirect/return-url binding once the
+ // relationship between authorization RedirectUri and Hub ReturnUrl
+ // is explicitly defined. They are not currently equivalent concepts.
+ //if (!string.Equals(original.RedirectUri, completion.RedirectUri, StringComparison.Ordinal))
+ // return false;
+
+ // TODO: Add logical client-device binding. The physical device context
+ // of the Hub completion request is not necessarily the device context
+ // captured from the originating client.
+ //if (!IsDeviceValid(original.Device, completion.Device))
+ // return false;
return true;
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubCredentialResolver.cs
index 9dbf2d22..8399ca69 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubCredentialResolver.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubCredentialResolver.cs
@@ -7,10 +7,12 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure;
internal sealed class HubCredentialResolver : IHubCredentialResolver
{
private readonly IAuthStore _store;
+ private readonly IClock _clock;
- public HubCredentialResolver(IAuthStore store)
+ public HubCredentialResolver(IAuthStore store, IClock clock)
{
_store = store;
+ _clock = clock;
}
public async Task ResolveAsync(HubSessionId hubSessionId, CancellationToken ct = default)
@@ -20,6 +22,9 @@ public HubCredentialResolver(IAuthStore store)
if (artifact is not HubFlowArtifact flow)
return null;
+ if (flow.IsExpired(_clock.UtcNow))
+ return null;
+
if (flow.IsCompleted)
return null;
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs
index 186ed176..a37ca6d6 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs
@@ -65,6 +65,12 @@ await kernel.ExecuteAsync(async _ =>
{
var existing = await kernel.GetChainAsync(context.ChainId.Value);
+ // TODO(v0.x): Re-evaluate explicit ChainId semantics.
+ // When the caller explicitly supplies a ChainId but that chain cannot be found,
+ // the current behavior silently creates a new chain with a different ChainId.
+ // Once SemiHybrid/PureJwt and the complete chain lifecycle semantics are finalized,
+ // decide whether this should instead fail closed (e.g. chain-not-found/validation failure).
+ // Do not change without reviewing login, refresh, reauthentication and device-chain flows.
if (existing is null)
{
chain = UAuthSessionChain.Create(
@@ -229,15 +235,15 @@ await kernel.ExecuteAsync(async _ =>
metadata: context.Metadata
);
+ var newSession = newSessionUnbound.WithChain(chain.ChainId);
+
issued = new IssuedSession
{
- Session = newSessionUnbound,
+ Session = newSession,
OpaqueSessionId = opaqueSessionId,
IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid
};
- var newSession = issued.Session.WithChain(chain.ChainId);
-
await kernel.CreateSessionAsync(newSession);
var chainExpected = chain.Version;
var updatedChain = chain.RotateSession(newSession.SessionId, now, context.Claims);
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs
index 290b399e..c4d139fb 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs
@@ -77,10 +77,11 @@ UAuthMode.SemiHybrid or
if (persistence == RefreshTokenPersistence.Persist)
{
var store = _storeFactory.Create(flow.Tenant);
- await store.ExecuteAsync(async ct =>
+
+ await store.ExecuteAsync(async transactionCt =>
{
- await store.StoreAsync(stored, ct);
- });
+ await store.StoreAsync(stored, transactionCt);
+ }, ct);
}
return new RefreshTokenInfo
@@ -106,17 +107,20 @@ private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessi
private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthTokenOptions tokens, DateTimeOffset expires)
{
- var claims = new Dictionary
- {
- ["sub"] = context.UserKey.Value,
- ["tenant"] = context.Tenant
- };
+ var claims = new Dictionary();
+ // Custom/application claims are added first.
+ // Framework-owned security claims below always take precedence.
foreach (var kv in context.Claims)
claims[kv.Key] = kv.Value;
- if (context.SessionId != null)
- claims["sid"] = context.SessionId!;
+ // UltimateAuth-owned identity/security claims must never be overridable
+ // by caller-provided claims.
+ claims["sub"] = context.UserKey.Value;
+ claims["tenant"] = context.Tenant;
+
+ if (context.SessionId is AuthSessionId sessionId)
+ claims["sid"] = sessionId;
if (tokens.AddJwtIdClaim)
claims["jti"] = _opaqueGenerator.GenerateJwtId();
@@ -140,7 +144,7 @@ private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthToken
Token = jwt,
Format = TokenFormat.Jwt,
ExpiresAt = expires,
- SessionId = context.SessionId.ToString()
+ SessionId = context.SessionId?.ToString()
};
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs
deleted file mode 100644
index 2432db8a..00000000
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Abstractions;
-using CodeBeam.UltimateAuth.Core.Contracts;
-using CodeBeam.UltimateAuth.Core.Domain;
-
-namespace CodeBeam.UltimateAuth.Server.Infrastructure;
-
-public sealed class RevokeChainCommand : ISessionCommand
-{
- public SessionChainId ChainId { get; }
-
- public RevokeChainCommand(SessionChainId chainId)
- {
- ChainId = chainId;
- }
-
- public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct)
- {
- await issuer.RevokeChainAsync(context.Tenant, ChainId, context.At, ct);
- return Unit.Value;
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs
deleted file mode 100644
index ab3b2ce1..00000000
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Abstractions;
-using CodeBeam.UltimateAuth.Core.Contracts;
-using CodeBeam.UltimateAuth.Core.Domain;
-
-namespace CodeBeam.UltimateAuth.Server.Infrastructure;
-
-public sealed class RevokeRootCommand : ISessionCommand
-{
- public UserKey UserKey { get; }
-
- public RevokeRootCommand(UserKey userKey)
- {
- UserKey = userKey;
- }
-
- public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct)
- {
- await issuer.RevokeRootAsync(context.Tenant, UserKey, context.At, ct);
- return Unit.Value;
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs
deleted file mode 100644
index a2aa8f20..00000000
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Abstractions;
-using CodeBeam.UltimateAuth.Core.Contracts;
-using CodeBeam.UltimateAuth.Core.Domain;
-
-namespace CodeBeam.UltimateAuth.Server.Infrastructure;
-
-internal sealed record RevokeSessionCommand(AuthSessionId SessionId) : ISessionCommand
-{
- public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct)
- {
- return await issuer.RevokeSessionAsync(context.Tenant, SessionId, context.At, ct);
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs
deleted file mode 100644
index 70fc768f..00000000
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Abstractions;
-using CodeBeam.UltimateAuth.Core.Contracts;
-
-namespace CodeBeam.UltimateAuth.Server.Infrastructure;
-
-internal sealed record RotateSessionCommand(SessionRotationContext RotationContext) : ISessionCommand
-{
- public Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct)
- {
- return issuer.RotateSessionAsync(RotationContext, ct);
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/ServerMultiTenantOptionsAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ServerMultiTenantOptionsAccessor.cs
new file mode 100644
index 00000000..351ef1a8
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ServerMultiTenantOptionsAccessor.cs
@@ -0,0 +1,18 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Options;
+using CodeBeam.UltimateAuth.Server.Options;
+using Microsoft.Extensions.Options;
+
+namespace CodeBeam.UltimateAuth.Server.Infrastructure;
+
+internal sealed class ServerMultiTenantOptionsAccessor : IUAuthMultiTenantOptionsAccessor
+{
+ private readonly IOptions _options;
+
+ public ServerMultiTenantOptionsAccessor(IOptions options)
+ {
+ _options = options;
+ }
+
+ public UAuthMultiTenantOptions MultiTenant => _options.Value.MultiTenant;
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs
index 1a687013..8a72f7d5 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs
@@ -35,12 +35,20 @@ public CompositeSessionIdResolver(IEnumerable resolvers
return null;
}
- private bool IsEnabled(string name) => name switch
+ private bool IsEnabled(string name)
{
- "Bearer" => _options.EnableBearer,
- "Header" => _options.EnableHeader,
- "Cookie" => _options.EnableCookie,
- "Query" => _options.EnableQuery,
- _ => false
- };
+ if (name.Equals("Bearer", StringComparison.OrdinalIgnoreCase))
+ return _options.EnableBearer;
+
+ if (name.Equals("Header", StringComparison.OrdinalIgnoreCase))
+ return _options.EnableHeader;
+
+ if (name.Equals("Cookie", StringComparison.OrdinalIgnoreCase))
+ return _options.EnableCookie;
+
+ if (name.Equals("Query", StringComparison.OrdinalIgnoreCase))
+ return _options.EnableQuery;
+
+ return false;
+ }
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthJwtValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthJwtValidator.cs
new file mode 100644
index 00000000..acf118f1
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthJwtValidator.cs
@@ -0,0 +1,83 @@
+//using CodeBeam.UltimateAuth.Core.Abstractions;
+//using CodeBeam.UltimateAuth.Core.Domain;
+//using CodeBeam.UltimateAuth.Core.Contracts;
+//using Microsoft.IdentityModel.JsonWebTokens;
+//using Microsoft.IdentityModel.Tokens;
+//using System.Security.Claims;
+//using CodeBeam.UltimateAuth.Core.MultiTenancy;
+
+//namespace CodeBeam.UltimateAuth.Server.Infrastructure;
+
+//internal sealed class UAuthJwtValidator : IJwtValidator
+//{
+// private readonly JsonWebTokenHandler _jwtHandler;
+// private readonly TokenValidationParameters _jwtParameters;
+// private readonly IUserIdConverterResolver _converters;
+
+// public UAuthJwtValidator(TokenValidationParameters jwtParameters, IUserIdConverterResolver converters)
+// {
+// _jwtHandler = new JsonWebTokenHandler();
+// _jwtParameters = jwtParameters;
+// _converters = converters;
+// }
+
+// public async Task> ValidateAsync(string token, CancellationToken ct = default)
+// {
+// var result = await _jwtHandler.ValidateTokenAsync(token, _jwtParameters);
+
+// if (!result.IsValid)
+// {
+// return TokenValidationResult.Invalid(TokenFormat.Jwt, MapJwtError(result.Exception));
+// }
+
+// var jwt = (JsonWebToken)result.SecurityToken;
+// var claims = jwt.Claims.ToArray();
+
+// var converter = _converters.GetConverter();
+
+// var userIdString = jwt.GetClaim(ClaimTypes.NameIdentifier)?.Value ?? jwt.GetClaim("sub")?.Value;
+// if (string.IsNullOrWhiteSpace(userIdString))
+// {
+// return TokenValidationResult.Invalid(TokenFormat.Jwt, TokenInvalidReason.MissingSubject);
+// }
+
+// TUserId userId;
+// try
+// {
+// userId = converter.FromString(userIdString);
+// }
+// catch
+// {
+// return TokenValidationResult.Invalid(TokenFormat.Jwt, TokenInvalidReason.Malformed);
+// }
+
+// var tenantId = jwt.GetClaim("tenant")?.Value ?? jwt.GetClaim("tid")?.Value;
+// AuthSessionId? sessionId = null;
+// var sid = jwt.GetClaim("sid")?.Value;
+// if (AuthSessionId.TryCreate(sid, out AuthSessionId ssid))
+// {
+// sessionId = ssid;
+// }
+
+// return TokenValidationResult.Valid(
+// format: TokenFormat.Jwt,
+// tenant: TenantKey.FromExternal(tenantId),
+// userId,
+// sessionId: sessionId,
+// claims: claims,
+// expiresAt: jwt.ValidTo);
+// }
+
+// private static TokenInvalidReason MapJwtError(Exception? ex)
+// {
+// return ex switch
+// {
+// SecurityTokenExpiredException => TokenInvalidReason.Expired,
+// SecurityTokenInvalidSignatureException => TokenInvalidReason.SignatureInvalid,
+// SecurityTokenInvalidAudienceException => TokenInvalidReason.AudienceMismatch,
+// SecurityTokenInvalidIssuerException => TokenInvalidReason.IssuerMismatch,
+// _ => TokenInvalidReason.Invalid
+// };
+// }
+
+//}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IdentifierUniquenessResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IdentifierUniquenessResolver.cs
new file mode 100644
index 00000000..5d9b922f
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IdentifierUniquenessResolver.cs
@@ -0,0 +1,24 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Server.Options;
+using CodeBeam.UltimateAuth.Users.Contracts;
+
+namespace CodeBeam.UltimateAuth.Server.Infrastructure;
+
+// TODO(policy): Move identifier uniqueness decision/enforcement to the Policy layer.
+public static class IdentifierUniquenessResolver
+{
+ public static UniquenessScope GetScope(UAuthServerOptions options, UserIdentifierType type)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var uniqueness = options.Identifiers.Uniqueness;
+
+ return type switch
+ {
+ UserIdentifierType.Username => uniqueness.Username,
+ UserIdentifierType.Email => uniqueness.Email,
+ UserIdentifierType.Phone => uniqueness.Phone,
+ _ => uniqueness.Custom
+ };
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs
index 3d16c55a..79c2bfb9 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs
@@ -1,17 +1,20 @@
using CodeBeam.UltimateAuth.Core.Contracts;
-using CodeBeam.UltimateAuth.Users.Contracts;
+using CodeBeam.UltimateAuth.Server.Services;
using CodeBeam.UltimateAuth.Users;
+using CodeBeam.UltimateAuth.Users.Contracts;
namespace CodeBeam.UltimateAuth.Server.Infrastructure;
public sealed class UserCreateValidator : IUserCreateValidator
{
private readonly IUserIdentifierValidator _identifierValidator;
+ private readonly IUserIdentifierAvailabilityService _identifierAvailability;
private readonly IUserProfileValidator _profileValidator;
- public UserCreateValidator(IUserIdentifierValidator identifierValidator, IUserProfileValidator profileValidator)
+ public UserCreateValidator(IUserIdentifierValidator identifierValidator, IUserIdentifierAvailabilityService identifierAvailability, IUserProfileValidator profileValidator)
{
_identifierValidator = identifierValidator;
+ _identifierAvailability = identifierAvailability;
_profileValidator = profileValidator;
}
@@ -35,6 +38,23 @@ public async Task ValidateAsync(AccessContext context
}, ct);
errors.AddRange(r.Errors);
+
+ if (r.IsValid)
+ {
+ var availability = await _identifierAvailability.CheckAsync(
+ context,
+ new CheckUserIdentifierAvailabilityRequest
+ {
+ Type = UserIdentifierType.Username,
+ Value = request.UserName
+ },
+ ct);
+
+ if (!availability.IsAvailable)
+ {
+ errors.Add(new UAuthValidationError("username_unavailable", "username"));
+ }
+ }
}
if (!string.IsNullOrWhiteSpace(request.Email))
@@ -46,6 +66,23 @@ public async Task ValidateAsync(AccessContext context
}, ct);
errors.AddRange(r.Errors);
+
+ if (r.IsValid)
+ {
+ var availability = await _identifierAvailability.CheckAsync(
+ context,
+ new CheckUserIdentifierAvailabilityRequest
+ {
+ Type = UserIdentifierType.Email,
+ Value = request.Email
+ },
+ ct);
+
+ if (!availability.IsAvailable)
+ {
+ errors.Add(new UAuthValidationError("email_unavailable", "email"));
+ }
+ }
}
if (!string.IsNullOrWhiteSpace(request.Phone))
@@ -57,6 +94,25 @@ public async Task ValidateAsync(AccessContext context
}, ct);
errors.AddRange(r.Errors);
+
+ if (r.IsValid)
+ {
+ // TODO: CheckAsync also validates identifiers, make them effective.
+ // TODO: This guard doesn't work with concurrent requests.
+ var availability = await _identifierAvailability.CheckAsync(context,
+ new CheckUserIdentifierAvailabilityRequest
+ {
+ Type = UserIdentifierType.Phone,
+ Value = request.Phone
+ },
+ ct);
+
+ if (!availability.IsAvailable)
+ {
+ errors.Add(
+ new UAuthValidationError("phone_unavailable", "phone"));
+ }
+ }
}
var effectiveDisplayName =
diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs
index 45365641..17cf6471 100644
--- a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs
@@ -47,7 +47,7 @@ public async Task Invoke(HttpContext context)
Device = device
});
- context.Items["__UAuth.SessionValidationResult"] = result;
+ context.Items[UAuthConstants.HttpItems.SessionValidationResult] = result;
await _next(context);
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs
index 7a22310c..bf907c26 100644
--- a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs
@@ -1,10 +1,8 @@
-using CodeBeam.UltimateAuth.Core.Defaults;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Defaults;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
-using CodeBeam.UltimateAuth.Core.Options;
using CodeBeam.UltimateAuth.Server.MultiTenancy;
-using CodeBeam.UltimateAuth.Server.Options;
using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.Options;
namespace CodeBeam.UltimateAuth.Server.Middlewares;
@@ -17,12 +15,12 @@ public TenantMiddleware(RequestDelegate next)
_next = next;
}
- public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, IOptions options)
+ public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, IUAuthMultiTenantOptionsAccessor options)
{
- var opts = options.Value;
+ var opts = options.MultiTenant;
TenantResolutionResult resolution;
- if (!opts.MultiTenant.Enabled)
+ if (!opts.Enabled)
{
context.Items[UAuthConstants.HttpItems.TenantContextKey] = UAuthTenantContext.SingleTenant();
await _next(context);
diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs
deleted file mode 100644
index 1612e00a..00000000
--- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-using CodeBeam.UltimateAuth.Core.MultiTenancy;
-using CodeBeam.UltimateAuth.Core.Options;
-
-namespace CodeBeam.UltimateAuth.Server.MultiTenancy;
-
-public static class UAuthTenantContextFactory
-{
- public static UAuthTenantContext Create(string? rawTenantId, UAuthMultiTenantOptions options)
- {
- if (!options.Enabled)
- return UAuthTenantContext.SingleTenant();
-
- if (string.IsNullOrWhiteSpace(rawTenantId))
- {
- //if (options.RequireTenant)
- // throw new InvalidOperationException("Tenant is required but could not be resolved.");
-
- throw new InvalidOperationException("Tenant could not be resolved.");
- }
-
- var tenantId = options.NormalizeToLowercase
- ? rawTenantId.Trim().ToLowerInvariant()
- : rawTenantId.Trim();
-
- var tenantKey = TenantKey.FromExternal(tenantId);
- return UAuthTenantContext.Resolved(tenantKey);
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs
index 440942ec..934d33a0 100644
--- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs
+++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs
@@ -1,4 +1,5 @@
-using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
using CodeBeam.UltimateAuth.Core.Options;
using CodeBeam.UltimateAuth.Server.Options;
using Microsoft.AspNetCore.Http;
@@ -9,12 +10,12 @@ namespace CodeBeam.UltimateAuth.Server.MultiTenancy;
public sealed class UAuthTenantResolver : ITenantResolver
{
private readonly ITenantIdResolver _idResolver;
- private readonly UAuthServerOptions _options;
+ private readonly IUAuthMultiTenantOptionsAccessor _options;
- public UAuthTenantResolver(ITenantIdResolver idResolver, IOptions options)
+ public UAuthTenantResolver(ITenantIdResolver idResolver, IUAuthMultiTenantOptionsAccessor options)
{
_idResolver = idResolver;
- _options = options.Value;
+ _options = options;
}
public async Task ResolveAsync(HttpContext context)
diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs
index f5bab9db..14429d4c 100644
--- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs
+++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs
@@ -6,6 +6,7 @@
namespace CodeBeam.UltimateAuth.Server.ResourceApi;
+// TODO: Resource API calls make two calls to here. Investigate.
internal sealed class RemoteSessionValidator : ISessionValidator
{
private readonly HttpClient _http;
@@ -40,11 +41,11 @@ public async Task ValidateSessionAsync(SessionValidatio
if (!response.IsSuccessStatusCode)
return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId);
- var dto = await response.Content.ReadFromJsonAsync(cancellationToken: ct);
+ var dto = await response.Content.ReadFromJsonAsync(cancellationToken: ct);
if (dto is null)
return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId);
- return SessionValidationMapper.ToDomain(dto);
+ return SessionValidationMapper.ToDomain(dto, context.SessionId);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceApiMultiTenantOptionsAccessor.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceApiMultiTenantOptionsAccessor.cs
new file mode 100644
index 00000000..00a3b4c0
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceApiMultiTenantOptionsAccessor.cs
@@ -0,0 +1,18 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Options;
+using CodeBeam.UltimateAuth.Server.Options;
+using Microsoft.Extensions.Options;
+
+namespace CodeBeam.UltimateAuth.Server.ResourceApi;
+
+internal sealed class ResourceApiMultiTenantOptionsAccessor : IUAuthMultiTenantOptionsAccessor
+{
+ private readonly IOptions _options;
+
+ public ResourceApiMultiTenantOptionsAccessor(IOptions options)
+ {
+ _options = options;
+ }
+
+ public UAuthMultiTenantOptions MultiTenant => _options.Value.MultiTenant;
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs
index 7d0ced82..b9811f0d 100644
--- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs
+++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs
@@ -1,65 +1,65 @@
-using CodeBeam.UltimateAuth.Core;
-using CodeBeam.UltimateAuth.Core.Abstractions;
-using CodeBeam.UltimateAuth.Core.Contracts;
-using CodeBeam.UltimateAuth.Core.Defaults;
-using CodeBeam.UltimateAuth.Core.Domain;
-using CodeBeam.UltimateAuth.Core.Options;
-using Microsoft.AspNetCore.Http;
+//using CodeBeam.UltimateAuth.Core;
+//using CodeBeam.UltimateAuth.Core.Abstractions;
+//using CodeBeam.UltimateAuth.Core.Contracts;
+//using CodeBeam.UltimateAuth.Core.Defaults;
+//using CodeBeam.UltimateAuth.Core.Domain;
+//using CodeBeam.UltimateAuth.Core.Options;
+//using Microsoft.AspNetCore.Http;
-namespace CodeBeam.UltimateAuth.Server.ResourceApi;
+//namespace CodeBeam.UltimateAuth.Server.ResourceApi;
-internal sealed class ResourceAuthContextFactory : IAuthContextFactory
-{
- private readonly IHttpContextAccessor _http;
- private readonly IClock _clock;
+//internal sealed class ResourceAuthContextFactory : IAuthContextFactory
+//{
+// private readonly IHttpContextAccessor _http;
+// private readonly IClock _clock;
- public ResourceAuthContextFactory(IHttpContextAccessor http, IClock clock)
- {
- _http = http;
- _clock = clock;
- }
+// public ResourceAuthContextFactory(IHttpContextAccessor http, IClock clock)
+// {
+// _http = http;
+// _clock = clock;
+// }
- public AuthContext Create(DateTimeOffset? at = null)
- {
- var ctx = _http.HttpContext!;
+// public AuthContext Create(DateTimeOffset? at = null)
+// {
+// var ctx = _http.HttpContext!;
- var result = ctx.Items[UAuthConstants.HttpItems.SessionValidationResult] as SessionValidationResult;
+// var result = ctx.Items[UAuthConstants.HttpItems.SessionValidationResult] as SessionValidationResult;
- DeviceContext device = result?.BoundDeviceId is { } deviceId
- ? DeviceContext.Create(DeviceId.Create(deviceId.Value))
- : DeviceContext.Anonymous();
+// DeviceContext device = result?.BoundDeviceId is { } deviceId
+// ? DeviceContext.Create(DeviceId.Create(deviceId.Value))
+// : DeviceContext.Anonymous();
- if (result is null || !result.IsValid)
- {
- return new AuthContext
- {
- Tenant = default!,
- Operation = AuthOperation.ResourceAccess,
- Mode = UAuthMode.PureOpaque,
- ClientProfile = UAuthClientProfile.Api,
- Device = device,
- At = at ?? _clock.UtcNow,
- Session = null
- };
- }
+// if (result is null || !result.IsValid)
+// {
+// return new AuthContext
+// {
+// Tenant = default!,
+// Operation = AuthOperation.ResourceAccess,
+// Mode = UAuthMode.PureOpaque,
+// ClientProfile = UAuthClientProfile.Api,
+// Device = device,
+// At = at ?? _clock.UtcNow,
+// Session = null
+// };
+// }
- return new AuthContext
- {
- Tenant = result.Tenant,
- Operation = AuthOperation.ResourceAccess,
- Mode = UAuthMode.PureOpaque, // TODO: Think about resolver.
- ClientProfile = UAuthClientProfile.Api,
- Device = device,
- At = at ?? _clock.UtcNow,
+// return new AuthContext
+// {
+// Tenant = result.Tenant,
+// Operation = AuthOperation.ResourceAccess,
+// Mode = UAuthMode.PureOpaque, // TODO: Think about resolver.
+// ClientProfile = UAuthClientProfile.Api,
+// Device = device,
+// At = at ?? _clock.UtcNow,
- Session = new SessionSecurityContext
- {
- UserKey = result.UserKey,
- SessionId = result.SessionId!.Value,
- State = result.State,
- ChainId = result.ChainId,
- BoundDeviceId = result.BoundDeviceId
- }
- };
- }
-}
\ No newline at end of file
+// Session = new SessionSecurityContext
+// {
+// UserKey = result.UserKey,
+// SessionId = result.SessionId!.Value,
+// State = result.State,
+// ChainId = result.ChainId,
+// BoundDeviceId = result.BoundDeviceId
+// }
+// };
+// }
+//}
\ No newline at end of file
diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierAvailabilityService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUserIdentifierAvailabilityService.cs
similarity index 86%
rename from src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierAvailabilityService.cs
rename to src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUserIdentifierAvailabilityService.cs
index 07f2e961..9e6cf4d8 100644
--- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierAvailabilityService.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUserIdentifierAvailabilityService.cs
@@ -1,7 +1,7 @@
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Users.Contracts;
-namespace CodeBeam.UltimateAuth.Users.Reference;
+namespace CodeBeam.UltimateAuth.Server.Services;
public interface IUserIdentifierAvailabilityService
{
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/HubFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/HubFlowService.cs
index 6081eb86..1faefb6b 100644
--- a/src/CodeBeam.UltimateAuth.Server/Services/HubFlowService.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Services/HubFlowService.cs
@@ -1,5 +1,6 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Errors;
using CodeBeam.UltimateAuth.Server.Contracts;
using CodeBeam.UltimateAuth.Server.Options;
using CodeBeam.UltimateAuth.Server.Stores;
@@ -13,10 +14,7 @@ internal sealed class HubFlowService : IHubFlowService
private readonly IClock _clock;
private readonly UAuthServerOptions _options;
- public HubFlowService(
- IAuthStore authStore,
- IClock clock,
- IOptions options)
+ public HubFlowService(IAuthStore authStore, IClock clock, IOptions options)
{
_authStore = authStore;
_clock = clock;
@@ -61,7 +59,13 @@ public async Task ContinuePkceAsync(string hubSessionId, string authorizationCod
var artifact = await _authStore.GetAsync(key, ct) as HubFlowArtifact;
if (artifact is null)
- throw new InvalidOperationException("Hub session not found.");
+ throw new UAuthValidationException("Hub session not found.");
+
+ if (artifact.IsExpired(_clock.UtcNow))
+ throw new UAuthValidationException("Hub session expired.");
+
+ if (artifact.IsCompleted)
+ throw new UAuthValidationException("Hub session already completed.");
artifact.Payload.Set("authorization_code", authorizationCode);
artifact.Payload.Set("code_verifier", codeVerifier);
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs
index c64bff3e..ec17b4c9 100644
--- a/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs
@@ -78,15 +78,13 @@ public async Task CompleteAsync(AuthFlowContext auth, PkceCo
};
}
- var validation = _validator.Validate(
- artifact,
- request.CodeVerifier,
- new PkceContextSnapshot(
- clientProfile: artifact.Context.ClientProfile,
- tenant: artifact.Context.Tenant,
- redirectUri: artifact.Context.RedirectUri,
- device: artifact.Context.Device),
- _clock.UtcNow);
+ var completionContext = new PkceContextSnapshot(
+ clientProfile: auth.ClientProfile,
+ tenant: auth.Tenant,
+ redirectUri: request.ReturnUrl,
+ device: auth.Device);
+
+ var validation = _validator.Validate(artifact, request.CodeVerifier, completionContext, _clock.UtcNow);
if (!validation.Success)
{
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs
deleted file mode 100644
index 18adbfcd..00000000
--- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs
+++ /dev/null
@@ -1,83 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Abstractions;
-using CodeBeam.UltimateAuth.Core.Domain;
-using CodeBeam.UltimateAuth.Core.Contracts;
-using Microsoft.IdentityModel.JsonWebTokens;
-using Microsoft.IdentityModel.Tokens;
-using System.Security.Claims;
-using CodeBeam.UltimateAuth.Core.MultiTenancy;
-
-namespace CodeBeam.UltimateAuth.Server.Services;
-
-internal sealed class UAuthJwtValidator : IJwtValidator
-{
- private readonly JsonWebTokenHandler _jwtHandler;
- private readonly TokenValidationParameters _jwtParameters;
- private readonly IUserIdConverterResolver _converters;
-
- public UAuthJwtValidator(TokenValidationParameters jwtParameters, IUserIdConverterResolver converters)
- {
- _jwtHandler = new JsonWebTokenHandler();
- _jwtParameters = jwtParameters;
- _converters = converters;
- }
-
- public async Task> ValidateAsync(string token, CancellationToken ct = default)
- {
- var result = await _jwtHandler.ValidateTokenAsync(token, _jwtParameters);
-
- if (!result.IsValid)
- {
- return TokenValidationResult.Invalid(TokenFormat.Jwt, MapJwtError(result.Exception));
- }
-
- var jwt = (JsonWebToken)result.SecurityToken;
- var claims = jwt.Claims.ToArray();
-
- var converter = _converters.GetConverter();
-
- var userIdString = jwt.GetClaim(ClaimTypes.NameIdentifier)?.Value ?? jwt.GetClaim("sub")?.Value;
- if (string.IsNullOrWhiteSpace(userIdString))
- {
- return TokenValidationResult.Invalid(TokenFormat.Jwt, TokenInvalidReason.MissingSubject);
- }
-
- TUserId userId;
- try
- {
- userId = converter.FromString(userIdString);
- }
- catch
- {
- return TokenValidationResult.Invalid(TokenFormat.Jwt, TokenInvalidReason.Malformed);
- }
-
- var tenantId = jwt.GetClaim("tenant")?.Value ?? jwt.GetClaim("tid")?.Value;
- AuthSessionId? sessionId = null;
- var sid = jwt.GetClaim("sid")?.Value;
- if (AuthSessionId.TryCreate(sid, out AuthSessionId ssid))
- {
- sessionId = ssid;
- }
-
- return TokenValidationResult.Valid(
- format: TokenFormat.Jwt,
- tenant: TenantKey.FromExternal(tenantId),
- userId,
- sessionId: sessionId,
- claims: claims,
- expiresAt: jwt.ValidTo);
- }
-
- private static TokenInvalidReason MapJwtError(Exception? ex)
- {
- return ex switch
- {
- SecurityTokenExpiredException => TokenInvalidReason.Expired,
- SecurityTokenInvalidSignatureException => TokenInvalidReason.SignatureInvalid,
- SecurityTokenInvalidAudienceException => TokenInvalidReason.AudienceMismatch,
- SecurityTokenInvalidIssuerException => TokenInvalidReason.IssuerMismatch,
- _ => TokenInvalidReason.Invalid
- };
- }
-
-}
diff --git a/src/bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle/AssemblyVisibility.cs b/src/bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle/AssemblyVisibility.cs
new file mode 100644
index 00000000..ed166fcc
--- /dev/null
+++ b/src/bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle/AssemblyVisibility.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")]
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginDispatch.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginDispatch.razor
index af62ef65..183f19ce 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginDispatch.razor
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginDispatch.razor
@@ -3,6 +3,7 @@
@namespace CodeBeam.UltimateAuth.Client.Blazor
@using CodeBeam.UltimateAuth.Core.Defaults
@using Microsoft.AspNetCore.WebUtilities
+@inject IUAuthLoginPageResolver LoginPageResolver
@inject NavigationManager Nav
@code {
@@ -20,7 +21,7 @@
? value.ToString()
: null;
- var loginRoute = UAuthLoginPageDiscovery.Resolve();
+ var loginRoute = LoginPageResolver.Resolve();
string target;
string? safeReturnUrl = null;
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs
index 5a8f3d95..17d6ea68 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs
@@ -52,6 +52,8 @@ private static IServiceCollection AddUltimateAuthClientBlazorInternal(this IServ
services.AddScoped();
services.AddScoped();
+ services.TryAddSingleton();
+
services.AddAuthorizationCore();
return services;
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs
index a4b93b3e..84cc3af5 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs
@@ -1,46 +1,191 @@
using Microsoft.AspNetCore.Components;
-namespace CodeBeam.UltimateAuth.Client.Infrastructure;
+namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure;
///
-/// Discovers the login page route by scanning for a component decorated with the [UAuthLoginPage] attribute. If no such component is found, it defaults to "/login". If multiple components are found, an exception is thrown.
-/// The resolved route is cached for subsequent calls.
+/// Discovers the login page route from the component decorated with
+/// .
///
public static class UAuthLoginPageDiscovery
{
+ private const string DefaultLoginRoute = "/login";
+
private static string? _cached;
///
- /// Resolves the login page route by scanning for a component decorated with the [UAuthLoginPage] attribute. If no such component is found, it defaults to "/login".
- /// If multiple components are found, an exception is thrown.
+ /// Resolves the login page route by scanning loaded assemblies for a component
+ /// decorated with .
///
- ///
- ///
+ ///
+ /// Route selection order:
+ ///
+ /// - Preferred route explicitly configured on .
+ /// - Root route (/).
+ /// - Conventional login route (/login).
+ /// - First route in deterministic ordinal-ignore-case order.
+ /// - Default route (/login) when the component has no route.
+ ///
+ ///
public static string Resolve()
{
- if (_cached != null)
+ if (_cached is not null)
return _cached;
- var assemblies = AppDomain.CurrentDomain.GetAssemblies();
+ var candidates = AppDomain.CurrentDomain
+ .GetAssemblies()
+ .SelectMany(GetLoadableTypes)
+ .Where(HasLoginPageAttribute)
+ .ToArray();
+
+ if (candidates.Length == 0)
+ return _cached = DefaultLoginRoute;
+
+ if (candidates.Length > 1)
+ {
+ throw new InvalidOperationException(
+ "Multiple [UAuthLoginPage] components were found. " +
+ "Make sure only one component is marked as the UltimateAuth login page.");
+ }
+
+ return _cached = ResolveRoute(candidates[0]);
+ }
+
+ internal static string ResolveRoute(Type componentType)
+ {
+ ArgumentNullException.ThrowIfNull(componentType);
+
+ var loginPage = componentType
+ .GetCustomAttributes(typeof(UAuthLoginPageAttribute), inherit: true)
+ .Cast()
+ .SingleOrDefault();
+
+ if (loginPage is null)
+ {
+ throw new InvalidOperationException(
+ $"Component '{componentType.FullName}' is not decorated with [UAuthLoginPage].");
+ }
+
+ var routes = componentType
+ .GetCustomAttributes(typeof(RouteAttribute), inherit: true)
+ .Cast()
+ .Select(x => x.Template)
+ .ToArray();
+
+ return ResolveRoute(
+ loginPage,
+ routes,
+ componentType.FullName);
+ }
+
+ internal static string ResolveRoute(
+ UAuthLoginPageAttribute loginPage,
+ IEnumerable routes,
+ string? componentName = null)
+ {
+ ArgumentNullException.ThrowIfNull(loginPage);
+ ArgumentNullException.ThrowIfNull(routes);
+
+ var normalizedRoutes = routes
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(NormalizeRoute)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ if (!string.IsNullOrWhiteSpace(loginPage.PreferredRoute))
+ {
+ var normalizedPreferred =
+ NormalizeRoute(loginPage.PreferredRoute);
+
+ var preferred = normalizedRoutes.FirstOrDefault(x =>
+ string.Equals(
+ x,
+ normalizedPreferred,
+ StringComparison.OrdinalIgnoreCase));
- var candidates = assemblies
- .SelectMany(a =>
+ if (preferred is null)
{
- try { return a.GetTypes(); }
- catch { return Array.Empty(); }
- })
- .Where(t => t.GetCustomAttributes(typeof(UAuthLoginPageAttribute), true).Any())
- .ToList();
+ var componentDescription =
+ string.IsNullOrWhiteSpace(componentName)
+ ? "the login page component"
+ : $"component '{componentName}'";
+
+ throw new InvalidOperationException(
+ $"Preferred login route '{loginPage.PreferredRoute}' " +
+ $"is not defined on {componentDescription}.");
+ }
+
+ return preferred;
+ }
+
+ var root = normalizedRoutes.FirstOrDefault(x =>
+ string.Equals(
+ x,
+ "/",
+ StringComparison.OrdinalIgnoreCase));
+
+ if (root is not null)
+ return root;
+
+ var login = normalizedRoutes.FirstOrDefault(x =>
+ string.Equals(
+ x,
+ DefaultLoginRoute,
+ StringComparison.OrdinalIgnoreCase));
+
+ if (login is not null)
+ return login;
+
+ if (normalizedRoutes.Length > 0)
+ {
+ return normalizedRoutes
+ .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
+ .First();
+ }
+
+ return DefaultLoginRoute;
+ }
+
+ private static IEnumerable GetLoadableTypes(
+ System.Reflection.Assembly assembly)
+ {
+ try
+ {
+ return assembly.GetTypes();
+ }
+ catch (System.Reflection.ReflectionTypeLoadException ex)
+ {
+ return ex.Types
+ .Where(x => x is not null)
+ .Cast();
+ }
+ catch
+ {
+ return Array.Empty();
+ }
+ }
+
+ private static bool HasLoginPageAttribute(Type type)
+ {
+ return type
+ .GetCustomAttributes(
+ typeof(UAuthLoginPageAttribute),
+ inherit: true)
+ .Any();
+ }
+
+ private static string NormalizeRoute(string route)
+ {
+ if (string.IsNullOrWhiteSpace(route))
+ return "/";
- if (candidates.Count == 0)
- return _cached = "/login";
+ route = route.Trim();
- if (candidates.Count > 1)
- throw new InvalidOperationException("Multiple [UAuthLoginPage] found. Make sure you only have one login page that attribute defined or define Navigation.LoginResolver explicitly.");
+ if (!route.StartsWith('/'))
+ route = "/" + route;
- var routeAttr = candidates[0].GetCustomAttributes(typeof(RouteAttribute), true).FirstOrDefault() as RouteAttribute;
+ if (route.Length > 1)
+ route = route.TrimEnd('/');
- _cached = routeAttr?.Template ?? "/login";
- return _cached;
+ return route;
}
-}
+}
\ No newline at end of file
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageResolver.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageResolver.cs
new file mode 100644
index 00000000..43a9dff8
--- /dev/null
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageResolver.cs
@@ -0,0 +1,11 @@
+using CodeBeam.UltimateAuth.Client.Infrastructure;
+
+namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure;
+
+internal sealed class UAuthLoginPageResolver : IUAuthLoginPageResolver
+{
+ public string Resolve()
+ {
+ return UAuthLoginPageDiscovery.Resolve();
+ }
+}
\ No newline at end of file
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthLoginPageResolver.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthLoginPageResolver.cs
new file mode 100644
index 00000000..72704a68
--- /dev/null
+++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthLoginPageResolver.cs
@@ -0,0 +1,6 @@
+namespace CodeBeam.UltimateAuth.Client.Infrastructure;
+
+public interface IUAuthLoginPageResolver
+{
+ string Resolve();
+}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs
index ab7db38c..d1e419d7 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs
@@ -6,4 +6,10 @@
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public sealed class UAuthLoginPageAttribute : Attribute
{
+ public string? PreferredRoute { get; }
+
+ public UAuthLoginPageAttribute(string? preferredRoute = null)
+ {
+ PreferredRoute = preferredRoute;
+ }
}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs
deleted file mode 100644
index d75b3543..00000000
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Domain;
-
-namespace CodeBeam.UltimateAuth.Credentials.Contracts;
-
-public static class CredentialTypeParser
-{
- private static readonly Dictionary _map =
- new(StringComparer.OrdinalIgnoreCase)
- {
- ["password"] = CredentialType.Password,
-
- ["otp"] = CredentialType.OneTimeCode,
- ["one-time-code"] = CredentialType.OneTimeCode,
-
- ["email-otp"] = CredentialType.EmailOtp,
- ["sms-otp"] = CredentialType.SmsOtp,
-
- ["totp"] = CredentialType.Totp,
-
- ["passkey"] = CredentialType.Passkey,
-
- ["certificate"] = CredentialType.Certificate,
- ["cert"] = CredentialType.Certificate,
-
- ["api-key"] = CredentialType.ApiKey,
- ["apikey"] = CredentialType.ApiKey,
-
- ["external"] = CredentialType.External
- };
-
- public static bool TryParse(string value, out CredentialType type) => _map.TryGetValue(value, out type);
-
- public static CredentialType ParseOrThrow(string value) => TryParse(value, out var type)
- ? type
- : throw new InvalidOperationException($"Unsupported credential type: '{value}'");
-}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs
deleted file mode 100644
index ad00b575..00000000
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-namespace CodeBeam.UltimateAuth.Credentials.Contracts;
-
-public sealed record CredentialChangeResult
-{
- public bool Succeeded { get; init; }
-
- ///
- /// Indicates whether security version / sessions were invalidated.
- ///
- public bool SecurityInvalidated { get; init; }
-
- public string? FailureReason { get; init; }
-}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs
deleted file mode 100644
index c116b8e6..00000000
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Domain;
-
-namespace CodeBeam.UltimateAuth.Credentials.Contracts;
-
-public sealed record CredentialProvisionResult
-{
- public required bool Succeeded { get; init; }
-
- public CredentialType? Type { get; init; }
-
- ///
- /// Indicates whether existing security state was affected.
- /// For initial provisioning this is usually false.
- ///
- public bool SecurityInvalidated { get; init; }
-
- public string? FailureReason { get; init; }
-
- public static CredentialProvisionResult Success(CredentialType type)
- => new()
- {
- Succeeded = true,
- Type = type,
- SecurityInvalidated = false
- };
-
- public static CredentialProvisionResult AlreadyExists(CredentialType type)
- => new()
- {
- Succeeded = true,
- Type = type,
- SecurityInvalidated = false
- };
-
- public static CredentialProvisionResult Failed(string reason)
- => new()
- {
- Succeeded = false,
- FailureReason = reason
- };
-}
diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/AssemblyVisibility.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/AssemblyVisibility.cs
new file mode 100644
index 00000000..ed166fcc
--- /dev/null
+++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/AssemblyVisibility.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")]
diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/SessionChainIdConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/SessionChainIdConverter.cs
deleted file mode 100644
index ff21b301..00000000
--- a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/SessionChainIdConverter.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Domain;
-using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
-
-namespace CodeBeam.UltimateAuth.EntityFrameworkCore;
-
-public sealed class SessionChainIdConverter : ValueConverter
-{
- public SessionChainIdConverter()
- : base(
- id => SessionChainIdEfConverter.ToDatabase(id),
- raw => SessionChainIdEfConverter.FromDatabase(raw))
- {
- }
-}
\ No newline at end of file
diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs
index 4c6181ae..c92b5de7 100644
--- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs
+++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs
@@ -33,4 +33,12 @@ private IPolicyScopeBuilder Add() where TPolicy : IAccessPolicy
public IPolicyScopeBuilder RequirePermission() => Add();
public IPolicyScopeBuilder RequireAuthenticated() => Add();
public IPolicyScopeBuilder DenyCrossTenant() => Add();
+
+ public IConditionalPolicyBuilder When(
+ Func predicate)
+ {
+ ArgumentNullException.ThrowIfNull(predicate);
+
+ return new ConditionalPolicyBuilder(_prefix, context => (_condition(context) == _expected) && predicate(context), _registry, _services);
+ }
}
diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs
index 9f400fae..b51a78db 100644
--- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs
+++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs
@@ -1,4 +1,6 @@
-namespace CodeBeam.UltimateAuth.Policies;
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Policies;
public interface IPolicyScopeBuilder
{
@@ -6,4 +8,6 @@ public interface IPolicyScopeBuilder
IPolicyScopeBuilder RequireSelf();
IPolicyScopeBuilder RequirePermission();
IPolicyScopeBuilder DenyCrossTenant();
+
+ IConditionalPolicyBuilder When(Func predicate);
}
diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs
index 4f73b643..e0cee43c 100644
--- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs
+++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs
@@ -3,10 +3,10 @@
public sealed class Argon2Options
{
// OWASP recommended baseline
- public int MemorySizeKb { get; init; } = 64 * 1024; // 64 MB
- public int Iterations { get; init; } = 3;
- public int Parallelism { get; init; } = Environment.ProcessorCount;
+ public int MemorySizeKb { get; set; } = 64 * 1024; // 64 MB
+ public int Iterations { get; set; } = 3;
+ public int Parallelism { get; set; } = Environment.ProcessorCount;
- public int SaltSize { get; init; } = 16;
- public int HashSize { get; init; } = 32;
+ public int SaltSize { get; set; } = 16;
+ public int HashSize { get; set; } = 32;
}
diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs
index 7e3b7875..f72fa2ae 100644
--- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs
+++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs
@@ -49,11 +49,11 @@ public bool Verify(PasswordHash hash, string secret)
!int.TryParse(parts[2], out var parallelism))
return false;
- var salt = Convert.FromBase64String(parts[3]);
- var expectedHash = Convert.FromBase64String(parts[4]);
-
try
{
+ var salt = Convert.FromBase64String(parts[3]);
+ var expectedHash = Convert.FromBase64String(parts[4]);
+
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(secret))
{
Salt = salt,
diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs
index 8dedbff7..ea1a0174 100644
--- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs
+++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs
@@ -2,6 +2,7 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
+using CodeBeam.UltimateAuth.Server.Services;
namespace CodeBeam.UltimateAuth.Users.Reference.Extensions;
public static class ServiceCollectionExtensions
diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs
index 08ab97cc..eff28d65 100644
--- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs
+++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs
@@ -8,6 +8,7 @@
using CodeBeam.UltimateAuth.Users.Contracts;
using CodeBeam.UltimateAuth.Users;
using Microsoft.Extensions.Options;
+using CodeBeam.UltimateAuth.Server.Services;
namespace CodeBeam.UltimateAuth.Users.Reference;
@@ -529,7 +530,6 @@ public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifie
if (userScopeResult.Exists)
throw new UAuthIdentifierConflictException("identifier_already_exists_for_user");
- // TODO(policy): Move identifier uniqueness decision/enforcement to the Policy layer.
await EnsureIdentifierUniquenessAsync(identifierStore, request.Type, normalized.Normalized, userKey, excludeIdentifierId: null, innerCt);
if (request.IsPrimary)
@@ -995,6 +995,7 @@ private async Task EnsureIdentifierUniquenessAsync(
}
}
+ // TODO(policy): Move identifier uniqueness decision/enforcement to the Policy layer.
private UniquenessScope GetUniquenessScope(UserIdentifierType type)
{
var uniqueness = _options.Identifiers.Uniqueness;
diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs
index 19afc1ff..7778eed5 100644
--- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs
+++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs
@@ -1,19 +1,24 @@
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Server.Infrastructure;
+using CodeBeam.UltimateAuth.Server.Options;
+using CodeBeam.UltimateAuth.Server.Services;
using CodeBeam.UltimateAuth.Users.Contracts;
using CodeBeam.UltimateAuth.Users.Reference;
+using Microsoft.Extensions.Options;
public sealed class UserIdentifierAvailabilityService : IUserIdentifierAvailabilityService
{
private readonly IUserIdentifierValidator _validator;
private readonly IIdentifierNormalizer _normalizer;
private readonly IUserIdentifierStoreFactory _storeFactory;
+ private readonly UAuthServerOptions _options;
- public UserIdentifierAvailabilityService(IUserIdentifierValidator validator, IIdentifierNormalizer normalizer, IUserIdentifierStoreFactory storeFactory)
+ public UserIdentifierAvailabilityService(IUserIdentifierValidator validator, IIdentifierNormalizer normalizer, IUserIdentifierStoreFactory storeFactory, IOptions options)
{
_validator = validator;
_normalizer = normalizer;
_storeFactory = storeFactory;
+ _options = options.Value;
}
public async Task CheckAsync(AccessContext context, CheckUserIdentifierAvailabilityRequest request, CancellationToken ct = default)
@@ -42,14 +47,44 @@ public async Task CheckAsync(AccessContext con
});
}
+ var uniquenessScope = IdentifierUniquenessResolver.GetScope(_options, request.Type);
+
+ if (uniquenessScope is UniquenessScope.None or UniquenessScope.WithinUser)
+ {
+ if (context.TargetUserKey is null)
+ {
+ return UserIdentifierAvailabilityResult.Available(normalized.Normalized);
+ }
+ }
+
var store = _storeFactory.Create(context.ResourceTenant);
- var existence = await store.ExistsAsync(
- new IdentifierExistenceQuery(
- request.Type,
- normalized.Normalized,
- IdentifierExistenceScope.TenantAny),
- ct);
+ var query = uniquenessScope switch
+ {
+ UniquenessScope.Tenant =>
+ new IdentifierExistenceQuery(
+ request.Type,
+ normalized.Normalized,
+ IdentifierExistenceScope.TenantAny),
+
+ UniquenessScope.WithinUser =>
+ new IdentifierExistenceQuery(
+ request.Type,
+ normalized.Normalized,
+ IdentifierExistenceScope.WithinUser,
+ context.TargetUserKey),
+
+ UniquenessScope.None =>
+ new IdentifierExistenceQuery(
+ request.Type,
+ normalized.Normalized,
+ IdentifierExistenceScope.WithinUser,
+ context.TargetUserKey),
+
+ _ => throw new InvalidOperationException($"Unsupported uniqueness scope '{uniquenessScope}'.")
+ };
+
+ var existence = await store.ExistsAsync(query, ct);
return existence.Exists
? UserIdentifierAvailabilityResult.Unavailable(normalized.Normalized)
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj
new file mode 100644
index 00000000..c5f95081
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Extensions/ServiceCollectionTestExtensions.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Extensions/ServiceCollectionTestExtensions.cs
new file mode 100644
index 00000000..abc023d8
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Extensions/ServiceCollectionTestExtensions.cs
@@ -0,0 +1,46 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore;
+
+internal static class ServiceCollectionTestExtensions
+{
+ public static void DecorateForTest(this IServiceCollection services, Func decorator) where TService : class
+ {
+ var descriptor = services.LastOrDefault(x => x.ServiceType == typeof(TService));
+
+ if (descriptor is null)
+ {
+ throw new InvalidOperationException($"Service '{typeof(TService).FullName}' is not registered.");
+ }
+
+ services.Remove(descriptor);
+
+ services.Add(
+ ServiceDescriptor.Describe(typeof(TService),
+ sp =>
+ {
+ var inner = CreateInstance(sp, descriptor);
+
+ return decorator(sp, inner);
+ },
+ descriptor.Lifetime));
+ }
+
+ private static TService CreateInstance(IServiceProvider serviceProvider, ServiceDescriptor descriptor) where TService : class
+ {
+ if (descriptor.ImplementationInstance is TService instance)
+ return instance;
+
+ if (descriptor.ImplementationFactory is not null)
+ {
+ return (TService)descriptor.ImplementationFactory(serviceProvider);
+ }
+
+ if (descriptor.ImplementationType is not null)
+ {
+ return (TService)ActivatorUtilities.CreateInstance(serviceProvider,descriptor.ImplementationType);
+ }
+
+ throw new InvalidOperationException($"Unable to construct decorated service '{typeof(TService).FullName}'.");
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/EfCoreTestRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/EfCoreTestRuntime.cs
new file mode 100644
index 00000000..013bb234
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/EfCoreTestRuntime.cs
@@ -0,0 +1,120 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.EntityFrameworkCore;
+using CodeBeam.UltimateAuth.EntityFrameworkCore.Extensions;
+using CodeBeam.UltimateAuth.Server.Extensions;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore;
+
+internal sealed class EfCoreTestRuntime : IAsyncDisposable
+{
+ private readonly SqliteConnection _connection;
+
+ public IServiceProvider Services { get; }
+
+ public IntegrationTestClock Clock { get; }
+
+ private EfCoreTestRuntime(
+ SqliteConnection connection,
+ IServiceProvider services,
+ IntegrationTestClock clock)
+ {
+ _connection = connection;
+ Services = services;
+ Clock = clock;
+ }
+
+ public static async Task CreateAsync(
+ Action? configureServices = null)
+ {
+ var connection =
+ new SqliteConnection("Data Source=:memory:");
+
+ await connection.OpenAsync();
+
+ var services = new ServiceCollection();
+
+ services.AddLogging();
+
+ // AddUltimateAuthServer registers ASP.NET Core authorization services.
+ // The test runtime therefore also needs the routing infrastructure
+ // normally supplied by WebApplication.
+ services.AddRouting();
+
+ var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build();
+
+ services.AddSingleton(configuration);
+
+ services
+ .AddUltimateAuthServer()
+ .AddUltimateAuthEntityFrameworkCore(db =>
+ {
+ db.UseSqlite(connection);
+ });
+
+ //
+ // Replace the production clock with a deterministic test clock.
+ //
+ var clock = new IntegrationTestClock();
+
+ services.RemoveAll();
+ services.AddSingleton(clock);
+
+ //
+ // Apply fault injection / test-specific overrides last.
+ //
+ configureServices?.Invoke(services);
+
+ var provider =
+ services.BuildServiceProvider(
+ new ServiceProviderOptions
+ {
+ ValidateScopes = true,
+ ValidateOnBuild = true
+ });
+
+ var runtime = new EfCoreTestRuntime(connection, provider, clock);
+
+ try
+ {
+ await runtime.InitializeDatabaseAsync();
+
+ return runtime;
+ }
+ catch
+ {
+ await runtime.DisposeAsync();
+ throw;
+ }
+ }
+
+ private async Task InitializeDatabaseAsync()
+ {
+ await using var scope =
+ Services.CreateAsyncScope();
+
+ var db =
+ scope.ServiceProvider
+ .GetRequiredService();
+
+ await db.Database.EnsureCreatedAsync();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (Services is IAsyncDisposable asyncDisposable)
+ {
+ await asyncDisposable.DisposeAsync();
+ }
+ else if (Services is IDisposable disposable)
+ {
+ disposable.Dispose();
+ }
+
+ await _connection.DisposeAsync();
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStore.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStore.cs
new file mode 100644
index 00000000..dfd5b153
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStore.cs
@@ -0,0 +1,102 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Users.Contracts;
+using CodeBeam.UltimateAuth.Users.Reference;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore;
+
+internal sealed class FailingUserIdentifierStore : IUserIdentifierStore
+{
+ private readonly IUserIdentifierStore _inner;
+ private readonly UserIdentifierStoreFaultState _fault;
+
+ public FailingUserIdentifierStore(IUserIdentifierStore inner, UserIdentifierStoreFaultState fault)
+ {
+ _inner = inner;
+ _fault = fault;
+ }
+
+ public Task GetAsync(
+ Guid key,
+ CancellationToken ct = default)
+ => _inner.GetAsync(key, ct);
+
+ public Task ExistsAsync(
+ Guid key,
+ CancellationToken ct = default)
+ => _inner.ExistsAsync(key, ct);
+
+ public async Task AddAsync(
+ UserIdentifier entity,
+ CancellationToken ct = default)
+ {
+ if (_fault.ShouldFail(entity))
+ {
+ throw new InvalidOperationException(
+ "simulated_identifier_store_failure");
+ }
+
+ await _inner.AddAsync(entity, ct);
+ }
+
+ public Task SaveAsync(
+ UserIdentifier entity,
+ long expectedVersion,
+ CancellationToken ct = default)
+ => _inner.SaveAsync(entity, expectedVersion, ct);
+
+ public Task DeleteAsync(
+ Guid key,
+ long expectedVersion,
+ DeleteMode deleteMode,
+ DateTimeOffset now,
+ CancellationToken ct = default)
+ => _inner.DeleteAsync(
+ key,
+ expectedVersion,
+ deleteMode,
+ now,
+ ct);
+
+ public Task ExistsAsync(
+ IdentifierExistenceQuery query,
+ CancellationToken ct = default)
+ => _inner.ExistsAsync(query, ct);
+
+ public Task> GetByUserAsync(
+ UserKey userKey,
+ CancellationToken ct = default)
+ => _inner.GetByUserAsync(userKey, ct);
+
+ public Task GetByIdAsync(
+ Guid id,
+ CancellationToken ct = default)
+ => _inner.GetByIdAsync(id, ct);
+
+ public Task GetAsync(
+ UserIdentifierType type,
+ string value,
+ CancellationToken ct = default)
+ => _inner.GetAsync(type, value, ct);
+
+ public Task> QueryAsync(
+ UserIdentifierQuery query,
+ CancellationToken ct = default)
+ => _inner.QueryAsync(query, ct);
+
+ public Task> GetByUsersAsync(
+ IReadOnlyList userKeys,
+ CancellationToken ct = default)
+ => _inner.GetByUsersAsync(userKeys, ct);
+
+ public Task DeleteByUserAsync(
+ UserKey userKey,
+ DeleteMode mode,
+ DateTimeOffset deletedAt,
+ CancellationToken ct = default)
+ => _inner.DeleteByUserAsync(
+ userKey,
+ mode,
+ deletedAt,
+ ct);
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStoreFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStoreFactory.cs
new file mode 100644
index 00000000..35d14694
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStoreFactory.cs
@@ -0,0 +1,26 @@
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Users.Reference;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore;
+
+internal sealed class FailingUserIdentifierStoreFactory
+ : IUserIdentifierStoreFactory
+{
+ private readonly IUserIdentifierStoreFactory _inner;
+ private readonly UserIdentifierStoreFaultState _fault;
+
+ public FailingUserIdentifierStoreFactory(
+ IUserIdentifierStoreFactory inner,
+ UserIdentifierStoreFaultState fault)
+ {
+ _inner = inner;
+ _fault = fault;
+ }
+
+ public IUserIdentifierStore Create(TenantKey tenant)
+ {
+ return new FailingUserIdentifierStore(
+ _inner.Create(tenant),
+ _fault);
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/IntegrationTestClock.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/IntegrationTestClock.cs
new file mode 100644
index 00000000..da1af454
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/IntegrationTestClock.cs
@@ -0,0 +1,48 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore;
+
+public sealed class IntegrationTestClock : IClock
+{
+ private readonly object _sync = new();
+
+ private DateTimeOffset _utcNow = new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ public DateTimeOffset UtcNow
+ {
+ get
+ {
+ lock (_sync)
+ {
+ return _utcNow;
+ }
+ }
+ }
+
+ public void Advance(TimeSpan duration)
+ {
+ if (duration < TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(nameof(duration));
+
+ lock (_sync)
+ {
+ _utcNow = _utcNow.Add(duration);
+ }
+ }
+
+ public void Set(DateTimeOffset value)
+ {
+ lock (_sync)
+ {
+ _utcNow = value.ToUniversalTime();
+ }
+ }
+
+ public void Reset()
+ {
+ lock (_sync)
+ {
+ _utcNow = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ }
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs
new file mode 100644
index 00000000..89d3d961
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs
@@ -0,0 +1,93 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore;
+
+internal static class TestAccessContext
+{
+ public static AccessContext WithAction(string action)
+ {
+ return new AccessContext(
+ actorUserKey: null,
+ actorTenant: TenantKey.Single,
+ isAuthenticated: false,
+ isSystemActor: false,
+ actorChainId: null,
+ resource: "test",
+ targetUserKey: null,
+ resourceTenant: TenantKey.Single,
+ action: action,
+ attributes: EmptyAttributes.Instance
+ );
+ }
+
+ public static AccessContext ForUser(
+ UserKey userKey,
+ string action,
+ TenantKey? tenant = null,
+ SessionChainId? actorChainId = null,
+ string resource = "identifier")
+ {
+ var t = tenant ?? TenantKey.Single;
+
+ return new AccessContext(
+ actorUserKey: userKey,
+ actorTenant: t,
+ isAuthenticated: true,
+ isSystemActor: false,
+ actorChainId: actorChainId,
+ resource: resource,
+ targetUserKey: userKey,
+ resourceTenant: t,
+ action: action,
+ attributes: EmptyAttributes.Instance
+ );
+ }
+
+ public static AccessContext ForTargetUser(
+ UserKey actorUserKey,
+ UserKey targetUserKey,
+ string action,
+ TenantKey? tenant = null,
+ SessionChainId? actorChainId = null,
+ string resource = "identifier")
+ {
+ var t = tenant ?? TenantKey.Single;
+
+ return new AccessContext(
+ actorUserKey: actorUserKey,
+ actorTenant: t,
+ isAuthenticated: true,
+ isSystemActor: false,
+ actorChainId: actorChainId,
+ resource: resource,
+ targetUserKey: targetUserKey,
+ resourceTenant: t,
+ action: action,
+ attributes: EmptyAttributes.Instance
+ );
+ }
+
+ public static AccessContext ForUserCreation(
+ UserKey actorUserKey,
+ string action,
+ TenantKey? tenant = null,
+ SessionChainId? actorChainId = null)
+ {
+ var t = tenant ?? TenantKey.Single;
+
+ return new AccessContext(
+ actorUserKey: actorUserKey,
+ actorTenant: t,
+ isAuthenticated: true,
+ isSystemActor: false,
+ actorChainId: actorChainId,
+ resource: "users",
+ targetUserKey: null,
+ resourceTenant: t,
+ action: action,
+ attributes: EmptyAttributes.Instance
+ );
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestUsers.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestUsers.cs
new file mode 100644
index 00000000..fa5385ae
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestUsers.cs
@@ -0,0 +1,9 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore;
+
+public static class TestUsers
+{
+ public static readonly UserKey Admin = UserKey.FromGuid(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"));
+ public static readonly UserKey User = UserKey.FromGuid(Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"));
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/UserIdentifierStoreFaultState.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/UserIdentifierStoreFaultState.cs
new file mode 100644
index 00000000..04ba0f21
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/UserIdentifierStoreFaultState.cs
@@ -0,0 +1,51 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Users.Contracts;
+using CodeBeam.UltimateAuth.Users.Reference;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore;
+
+internal sealed class UserIdentifierStoreFaultState
+{
+ private int _addAttempts;
+
+ public bool Enabled { get; private set; }
+
+ public int FailOnAddAttempt { get; private set; }
+
+ public int AddAttempts => _addAttempts;
+
+ public UserIdentifierType? LastAttemptedType { get; private set; }
+
+ public UserKey? LastAttemptedUserKey { get; private set; }
+
+ public void Enable(int failOnAddAttempt)
+ {
+ if (failOnAddAttempt <= 0)
+ throw new ArgumentOutOfRangeException(nameof(failOnAddAttempt));
+
+ _addAttempts = 0;
+ LastAttemptedType = null;
+ LastAttemptedUserKey = null;
+
+ FailOnAddAttempt = failOnAddAttempt;
+ Enabled = true;
+ }
+
+ public void Disable()
+ {
+ Enabled = false;
+ }
+
+ public bool ShouldFail(UserIdentifier identifier)
+ {
+ if (!Enabled)
+ return false;
+
+ var attempt = Interlocked.Increment(ref _addAttempts);
+
+ LastAttemptedType = identifier.Type;
+ LastAttemptedUserKey = identifier.UserKey;
+
+ return attempt == FailOnAddAttempt;
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs
new file mode 100644
index 00000000..486a598b
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs
@@ -0,0 +1,122 @@
+using CodeBeam.UltimateAuth.Core.Defaults;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Users.Contracts;
+using CodeBeam.UltimateAuth.Users.Reference;
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore.Users;
+
+public sealed class UserCreationAtomicityTests
+{
+ [Fact]
+ public async Task CreateUser_WhenIdentifierPersistenceFails_ShouldRollbackAllUserState()
+ {
+ var fault =
+ new UserIdentifierStoreFaultState();
+
+ await using var runtime =
+ await EfCoreTestRuntime.CreateAsync(
+ services =>
+ {
+ services.AddSingleton(fault);
+
+ services.DecorateForTest(
+ (sp, inner) => new FailingUserIdentifierStoreFactory(inner, sp.GetRequiredService()));
+ });
+
+ // Fault injection is enabled only after the runtime and database
+ // have been fully initialized.
+ fault.Enable(failOnAddAttempt: 2);
+
+ UserKey userKey;
+ TenantKey tenant;
+
+ //
+ // Execute user creation in its own DI scope.
+ //
+ using (var scope = runtime.Services.CreateScope())
+ {
+ var service = scope.ServiceProvider.GetRequiredService();
+
+ var context = TestAccessContext.ForUserCreation(TestUsers.Admin,UAuthActions.Users.CreateAnonymous);
+
+ tenant = context.ResourceTenant;
+
+ var request =
+ new CreateUserRequest
+ {
+ UserName = $"atomic-{Guid.NewGuid():N}",
+ Email = $"atomic-{Guid.NewGuid():N}@example.com",
+ FirstName = "Atomic",
+ LastName = "Failure"
+ };
+
+ var exception =
+ await Assert.ThrowsAsync(() => service.CreateUserAsync(context, request));
+
+ exception.Message.Should().Be("simulated_identifier_store_failure");
+
+ fault.AddAttempts.Should().Be(2);
+
+ fault.LastAttemptedType.Should().Be(UserIdentifierType.Email);
+
+ fault.LastAttemptedUserKey.Should().NotBeNull();
+
+ userKey = fault.LastAttemptedUserKey!.Value;
+ }
+
+ //
+ // Verify using a fresh scope / DbContext.
+ //
+ // This ensures that the assertions observe persisted database
+ // state rather than the DbContext change tracker used by the
+ // failed operation.
+ //
+ using (var scope = runtime.Services.CreateScope())
+ {
+ var lifecycleFactory =
+ scope.ServiceProvider
+ .GetRequiredService();
+
+ var profileFactory =
+ scope.ServiceProvider
+ .GetRequiredService();
+
+ var identifierFactory =
+ scope.ServiceProvider
+ .GetRequiredService();
+
+ var lifecycle =
+ await lifecycleFactory
+ .Create(tenant)
+ .GetAsync(
+ new UserLifecycleKey(
+ tenant,
+ userKey));
+
+ var profiles =
+ await profileFactory
+ .Create(tenant)
+ .GetAllProfilesByUserAsync(userKey);
+
+ var identifiers =
+ await identifierFactory
+ .Create(tenant)
+ .GetByUserAsync(userKey);
+
+ //
+ // Atomicity contract:
+ //
+ // A failed user creation must be observationally equivalent
+ // to the operation never having occurred.
+ //
+ lifecycle.Should().BeNull();
+
+ profiles.Should().BeEmpty();
+
+ identifiers.Should().BeEmpty();
+ }
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj b/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj
index 3871c097..db10f6e6 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj
@@ -14,6 +14,7 @@
+
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Extensions/ServiceCollectionTestExtensions.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Extensions/ServiceCollectionTestExtensions.cs
index f14a31b0..81939a7c 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Integration/Extensions/ServiceCollectionTestExtensions.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Extensions/ServiceCollectionTestExtensions.cs
@@ -4,58 +4,43 @@ namespace CodeBeam.UltimateAuth.Tests.Integration.Infrastructure;
internal static class ServiceCollectionTestExtensions
{
- public static void DecorateForTest(
- this IServiceCollection services,
- Func decorator)
- where TService : class
+ public static void DecorateForTest(this IServiceCollection services, Func decorator) where TService : class
{
- var descriptor = services.LastOrDefault(
- x => x.ServiceType == typeof(TService));
+ var descriptor = services.LastOrDefault(x => x.ServiceType == typeof(TService));
if (descriptor is null)
{
- throw new InvalidOperationException(
- $"Service '{typeof(TService).FullName}' is not registered.");
+ throw new InvalidOperationException($"Service '{typeof(TService).FullName}' is not registered.");
}
services.Remove(descriptor);
services.Add(
- ServiceDescriptor.Describe(
- typeof(TService),
+ ServiceDescriptor.Describe(typeof(TService),
sp =>
{
- var inner = CreateInstance(
- sp,
- descriptor);
+ var inner = CreateInstance(sp, descriptor);
return decorator(sp, inner);
},
descriptor.Lifetime));
}
- private static TService CreateInstance(
- IServiceProvider serviceProvider,
- ServiceDescriptor descriptor)
- where TService : class
+ private static TService CreateInstance(IServiceProvider serviceProvider, ServiceDescriptor descriptor) where TService : class
{
if (descriptor.ImplementationInstance is TService instance)
return instance;
if (descriptor.ImplementationFactory is not null)
{
- return (TService)descriptor
- .ImplementationFactory(serviceProvider);
+ return (TService)descriptor.ImplementationFactory(serviceProvider);
}
if (descriptor.ImplementationType is not null)
{
- return (TService)ActivatorUtilities.CreateInstance(
- serviceProvider,
- descriptor.ImplementationType);
+ return (TService)ActivatorUtilities.CreateInstance(serviceProvider,descriptor.ImplementationType);
}
- throw new InvalidOperationException(
- $"Unable to construct decorated service '{typeof(TService).FullName}'.");
+ throw new InvalidOperationException($"Unable to construct decorated service '{typeof(TService).FullName}'.");
}
}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/PkceFlowIntegrationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/PkceFlowIntegrationTests.cs
new file mode 100644
index 00000000..6781cd5b
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/PkceFlowIntegrationTests.cs
@@ -0,0 +1,588 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Defaults;
+using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure;
+using FluentAssertions;
+using Microsoft.AspNetCore.Mvc.Testing;
+using System.Net;
+using System.Net.Http.Json;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration;
+
+public sealed class PkceFlowIntegrationTests : IClassFixture
+{
+ private const string AuthorizeEndpoint = "/auth/pkce/authorize";
+ private const string CompleteEndpoint = "/auth/pkce/complete";
+ private readonly AuthServerFactory _factory;
+
+ public PkceFlowIntegrationTests(AuthServerFactory factory)
+ {
+ _factory = factory;
+ }
+
+ // ---------------------------------------------------------------------
+ // Happy path
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task Pkce_WithValidVerifierAndCredentials_ShouldAuthenticate()
+ {
+ _factory.Clock.Reset();
+
+ var user =
+ await _factory.CreateLoginUserAsync();
+
+ using var client =
+ CreateClient(
+ $"pkce-valid-{Guid.NewGuid():N}");
+
+ var verifier = CreateVerifier();
+ var challenge = CreateChallenge(verifier);
+
+ var authorization =
+ await AuthorizeAsync(
+ client,
+ challenge);
+
+ authorization.AuthorizationCode
+ .Should().NotBeNullOrWhiteSpace();
+
+ var response =
+ await CompleteAsync(
+ client,
+ authorization.AuthorizationCode,
+ verifier,
+ user.Identifier,
+ user.Secret);
+
+ ((int)response.StatusCode)
+ .Should().BeLessThan(500);
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out var cookies)
+ .Should().BeTrue();
+
+ cookies.Should().NotBeNullOrEmpty();
+ }
+
+ // ---------------------------------------------------------------------
+ // Consume-once / replay protection
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task Pkce_AfterSuccessfulCompletion_ShouldRejectReplayOfAuthorizationCode()
+ {
+ _factory.Clock.Reset();
+
+ var user =
+ await _factory.CreateLoginUserAsync();
+
+ using var client =
+ CreateClient(
+ $"pkce-replay-{Guid.NewGuid():N}");
+
+ var verifier = CreateVerifier();
+ var challenge = CreateChallenge(verifier);
+
+ var authorization =
+ await AuthorizeAsync(
+ client,
+ challenge);
+
+ var first =
+ await CompleteAsync(
+ client,
+ authorization.AuthorizationCode,
+ verifier,
+ user.Identifier,
+ user.Secret);
+
+ first.Headers
+ .TryGetValues("Set-Cookie", out var firstCookies)
+ .Should().BeTrue();
+
+ firstCookies.Should().NotBeNullOrEmpty();
+
+ // The authorization code was consumed by the first request.
+ var replay =
+ await CompleteAsync(
+ client,
+ authorization.AuthorizationCode,
+ verifier,
+ user.Identifier,
+ user.Secret);
+
+ replay.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ ((int)replay.StatusCode)
+ .Should().BeLessThan(500);
+ }
+
+ [Fact]
+ public async Task Pkce_WithInvalidVerifier_ShouldConsumeAuthorizationCode()
+ {
+ _factory.Clock.Reset();
+
+ var user =
+ await _factory.CreateLoginUserAsync();
+
+ using var client =
+ CreateClient(
+ $"pkce-invalid-verifier-{Guid.NewGuid():N}");
+
+ var correctVerifier = CreateVerifier();
+
+ var authorization =
+ await AuthorizeAsync(
+ client,
+ CreateChallenge(correctVerifier));
+
+ // First attempt deliberately uses the wrong proof.
+ var invalidAttempt =
+ await CompleteAsync(
+ client,
+ authorization.AuthorizationCode,
+ "definitely-wrong-verifier",
+ user.Identifier,
+ user.Secret);
+
+ invalidAttempt.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ // Even the correct verifier must no longer work.
+ // Consume-once means an attacker cannot probe a code repeatedly.
+ var retryWithCorrectVerifier =
+ await CompleteAsync(
+ client,
+ authorization.AuthorizationCode,
+ correctVerifier,
+ user.Identifier,
+ user.Secret);
+
+ retryWithCorrectVerifier.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ ((int)retryWithCorrectVerifier.StatusCode)
+ .Should().BeLessThan(500);
+ }
+
+ // ---------------------------------------------------------------------
+ // Expiration
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task Pkce_AfterAuthorizationCodeExpires_ShouldRejectCompletion()
+ {
+ _factory.Clock.Reset();
+
+ var user =
+ await _factory.CreateLoginUserAsync();
+
+ using var client =
+ CreateClient(
+ $"pkce-expired-{Guid.NewGuid():N}");
+
+ var verifier = CreateVerifier();
+
+ var authorization =
+ await AuthorizeAsync(
+ client,
+ CreateChallenge(verifier));
+
+ // Configure this to the actual AuthorizationCodeLifetimeSeconds
+ // if your test server default differs.
+ _factory.Clock.Advance(
+ TimeSpan.FromMinutes(10));
+
+ var response =
+ await CompleteAsync(
+ client,
+ authorization.AuthorizationCode,
+ verifier,
+ user.Identifier,
+ user.Secret);
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ ((int)response.StatusCode)
+ .Should().BeLessThan(500);
+ }
+
+ // ---------------------------------------------------------------------
+ // Credential boundary
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task Pkce_WithValidProofButInvalidCredentials_ShouldNotAuthenticate()
+ {
+ _factory.Clock.Reset();
+
+ var user =
+ await _factory.CreateLoginUserAsync();
+
+ using var client =
+ CreateClient(
+ $"pkce-bad-password-{Guid.NewGuid():N}");
+
+ var verifier = CreateVerifier();
+
+ var authorization =
+ await AuthorizeAsync(
+ client,
+ CreateChallenge(verifier));
+
+ var response =
+ await CompleteAsync(
+ client,
+ authorization.AuthorizationCode,
+ verifier,
+ user.Identifier,
+ "wrong-password");
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ ((int)response.StatusCode)
+ .Should().BeLessThan(500);
+ }
+
+ [Fact]
+ public async Task Pkce_WithInvalidProof_ShouldNotAuthenticateEvenWithValidCredentials()
+ {
+ _factory.Clock.Reset();
+
+ var user =
+ await _factory.CreateLoginUserAsync();
+
+ using var client =
+ CreateClient(
+ $"pkce-invalid-proof-{Guid.NewGuid():N}");
+
+ var verifier = CreateVerifier();
+
+ var authorization =
+ await AuthorizeAsync(
+ client,
+ CreateChallenge(verifier));
+
+ var response =
+ await CompleteAsync(
+ client,
+ authorization.AuthorizationCode,
+ "wrong-verifier",
+ user.Identifier,
+ user.Secret);
+
+ response.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ ((int)response.StatusCode)
+ .Should().BeLessThan(500);
+ }
+
+ // ---------------------------------------------------------------------
+ // Context binding regression
+ // ---------------------------------------------------------------------
+
+ //[Fact]
+ //public async Task Pkce_WhenCompletionReturnUrlDiffers_ShouldRejectCompletion()
+ //{
+ // _factory.Clock.Reset();
+
+ // var user =
+ // await _factory.CreateLoginUserAsync();
+
+ // using var client =
+ // CreateClient(
+ // $"pkce-context-return-{Guid.NewGuid():N}");
+
+ // var verifier = CreateVerifier();
+
+ // var authorization =
+ // await AuthorizeAsync(
+ // client,
+ // CreateChallenge(verifier),
+ // returnUrl: "/original-return");
+
+ // var response =
+ // await CompleteAsync(
+ // client,
+ // authorization.AuthorizationCode,
+ // verifier,
+ // user.Identifier,
+ // user.Secret,
+ // returnUrl: "/different-return");
+
+ // // SECURITY INVARIANT:
+ // //
+ // // The PKCE artifact was issued for /original-return.
+ // // Completion is attempting to use /different-return.
+ // //
+ // // This should fail through ContextMismatch.
+ // response.Headers
+ // .TryGetValues("Set-Cookie", out _)
+ // .Should().BeFalse();
+ //}
+
+ //[Fact]
+ //public async Task Pkce_WhenCompletionUsesDifferentDevice_ShouldRejectCompletion()
+ //{
+ // _factory.Clock.Reset();
+
+ // var user =
+ // await _factory.CreateLoginUserAsync();
+
+ // var verifier = CreateVerifier();
+
+ // using var authorizationClient =
+ // CreateClient(
+ // $"pkce-device-a-{Guid.NewGuid():N}");
+
+ // var authorization =
+ // await AuthorizeAsync(
+ // authorizationClient,
+ // CreateChallenge(verifier));
+
+ // using var completionClient =
+ // CreateClient(
+ // $"pkce-device-b-{Guid.NewGuid():N}");
+
+ // var response =
+ // await CompleteAsync(
+ // completionClient,
+ // authorization.AuthorizationCode,
+ // verifier,
+ // user.Identifier,
+ // user.Secret);
+
+ // // SECURITY INVARIANT:
+ // //
+ // // PKCE authorization was initiated from device A.
+ // // Device B must not be able to complete the same flow.
+ // response.Headers
+ // .TryGetValues("Set-Cookie", out _)
+ // .Should().BeFalse();
+ //}
+
+ [Fact]
+ public async Task Pkce_WithValidProofAndRepeatedInvalidCredentials_ShouldReachCredentialLockoutPipeline()
+ {
+ _factory.Clock.Reset();
+
+ var user =
+ await _factory.CreateLoginUserAsync();
+
+ using var client =
+ CreateClient(
+ $"pkce-lockout-{Guid.NewGuid():N}");
+
+ // -------------------------------------------------------------
+ // Attempt 1
+ //
+ // Each PKCE authorization code is consume-once, including when
+ // the subsequent credential authentication fails.
+ // Therefore every credential attempt needs a fresh PKCE flow.
+ // -------------------------------------------------------------
+
+ var verifier1 = CreateVerifier();
+
+ var authorization1 =
+ await AuthorizeAsync(
+ client,
+ CreateChallenge(verifier1));
+
+ var firstFailure =
+ await CompleteAsync(
+ client,
+ authorization1.AuthorizationCode,
+ verifier1,
+ user.Identifier,
+ "wrong-password-1");
+
+ firstFailure.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ ((int)firstFailure.StatusCode)
+ .Should().BeLessThan(500);
+
+ // -------------------------------------------------------------
+ // Attempt 2
+ //
+ // Fresh authorization code + valid PKCE proof, but another
+ // invalid credential attempt.
+ //
+ // This must reach the normal credential pipeline and trigger
+ // the configured lockout threshold.
+ // -------------------------------------------------------------
+
+ var verifier2 = CreateVerifier();
+
+ var authorization2 =
+ await AuthorizeAsync(
+ client,
+ CreateChallenge(verifier2));
+
+ var secondFailure =
+ await CompleteAsync(
+ client,
+ authorization2.AuthorizationCode,
+ verifier2,
+ user.Identifier,
+ "wrong-password-2");
+
+ secondFailure.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ ((int)secondFailure.StatusCode)
+ .Should().BeLessThan(500);
+
+ // -------------------------------------------------------------
+ // Attempt 3
+ //
+ // Credentials are now CORRECT.
+ //
+ // A fresh and valid PKCE transaction must still not authenticate
+ // because the credential/user security state should already be
+ // locked by the previous two credential failures.
+ // -------------------------------------------------------------
+
+ var verifier3 = CreateVerifier();
+
+ var authorization3 =
+ await AuthorizeAsync(
+ client,
+ CreateChallenge(verifier3));
+
+ var lockedAttempt =
+ await CompleteAsync(
+ client,
+ authorization3.AuthorizationCode,
+ verifier3,
+ user.Identifier,
+ user.Secret);
+
+ lockedAttempt.Headers
+ .TryGetValues("Set-Cookie", out _)
+ .Should().BeFalse();
+
+ ((int)lockedAttempt.StatusCode)
+ .Should().BeLessThan(500);
+ }
+
+ // ---------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------
+
+ private HttpClient CreateClient(
+ string deviceId)
+ {
+ var client =
+ _factory.CreateClient(
+ new WebApplicationFactoryClientOptions
+ {
+ AllowAutoRedirect = false,
+ HandleCookies = false
+ });
+
+ client.DefaultRequestHeaders.Add(
+ "Origin",
+ "https://localhost:6130");
+
+ client.DefaultRequestHeaders.Add(
+ "X-UDID",
+ deviceId);
+
+ return client;
+ }
+
+ private static async Task AuthorizeAsync(HttpClient client, string challenge, string? returnUrl = "/callback")
+ {
+ using var content = new FormUrlEncodedContent(
+ new Dictionary
+ {
+ ["code_challenge"] = challenge,
+ ["challenge_method"] = "S256",
+ ["redirect_uri"] = returnUrl ?? string.Empty
+ });
+
+ var response =
+ await client.PostAsync(
+ AuthorizeEndpoint,
+ content);
+
+ response.StatusCode
+ .Should().Be(HttpStatusCode.OK);
+
+ var result =
+ await response.Content
+ .ReadFromJsonAsync();
+
+ result.Should().NotBeNull();
+ result!.AuthorizationCode
+ .Should().NotBeNullOrWhiteSpace();
+
+ return result;
+ }
+
+ private static Task CompleteAsync(HttpClient client, string authorizationCode, string verifier,
+ string identifier, string secret, string? returnUrl = "/callback")
+ {
+ var values =
+ new Dictionary
+ {
+ ["authorization_code"] =
+ authorizationCode,
+
+ ["code_verifier"] =
+ verifier,
+
+ ["Identifier"] =
+ identifier,
+
+ ["Secret"] =
+ secret,
+
+ [UAuthConstants.Form.ReturnUrl] =
+ returnUrl ?? string.Empty
+ };
+
+ return client.PostAsync(
+ CompleteEndpoint,
+ new FormUrlEncodedContent(values));
+ }
+
+ private static string CreateVerifier()
+ {
+ return Convert
+ .ToBase64String(
+ RandomNumberGenerator.GetBytes(32))
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+ }
+
+ private static string CreateChallenge(
+ string verifier)
+ {
+ var hash =
+ SHA256.HashData(
+ Encoding.ASCII.GetBytes(verifier));
+
+ return Convert
+ .ToBase64String(hash)
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthenticationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthenticationTests.cs
new file mode 100644
index 00000000..05cbb8d9
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthenticationTests.cs
@@ -0,0 +1,265 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Server;
+using FluentAssertions;
+using Moq;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Security.Claims;
+using System.Timers;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration.ResourceApi;
+
+public sealed class ResourceApiAuthenticationTests
+{
+ private const string SessionValue = "test-resource-session-000000000000000000000001";
+ private static readonly TenantKey Tenant = TenantKey.FromExternal("tenant-1");
+ private static readonly UserKey User = UserKey.FromString("user-1");
+ private static readonly DateTimeOffset Now = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
+ private static AuthSessionId SessionId => AuthSessionId.Parse(SessionValue, null);
+ private static SessionChainId ChainId => SessionChainId.From(Guid.Parse("11111111-1111-1111-1111-111111111111"));
+ private static SessionRootId RootId => SessionRootId.From(Guid.Parse("22222222-2222-2222-2222-222222222222"));
+
+ // --------------------------------------------------
+ // Anonymous
+ // --------------------------------------------------
+
+ [Fact]
+ public async Task AnonymousEndpoint_WithoutCredential_ShouldReturnOk()
+ {
+ var validator = new Mock();
+
+ await using var host =
+ await ResourceApiTestHost.CreateAsync(
+ validator.Object);
+
+ var response = await host.Client.GetAsync(
+ "/__tests/resource/anonymous");
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ validator.Verify(
+ x => x.ValidateSessionAsync(
+ It.IsAny(),
+ It.IsAny()),
+ Times.Never);
+ }
+
+ // --------------------------------------------------
+ // 401
+ // --------------------------------------------------
+
+ [Fact]
+ public async Task ProtectedEndpoint_WithoutCredential_ShouldReturnUnauthorized()
+ {
+ var validator = new Mock();
+
+ await using var host =
+ await ResourceApiTestHost.CreateAsync(
+ validator.Object);
+
+ var response = await host.Client.GetAsync(
+ "/__tests/resource/authenticated");
+
+ response.StatusCode.Should()
+ .Be(HttpStatusCode.Unauthorized);
+
+ validator.Verify(
+ x => x.ValidateSessionAsync(
+ It.IsAny(),
+ It.IsAny()),
+ Times.Never);
+ }
+
+ // --------------------------------------------------
+ // Invalid session -> 401
+ // --------------------------------------------------
+
+ [Fact]
+ public async Task ProtectedEndpoint_WithInvalidSession_ShouldReturnUnauthorized()
+ {
+ var validator = new Mock();
+
+ validator
+ .Setup(x => x.ValidateSessionAsync(
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(
+ SessionValidationResult.Invalid(
+ SessionState.Revoked));
+
+ await using var host =
+ await ResourceApiTestHost.CreateAsync(
+ validator.Object);
+
+ AddSession(host.Client);
+
+ var response = await host.Client.GetAsync(
+ "/__tests/resource/authenticated");
+
+ response.StatusCode.Should()
+ .Be(HttpStatusCode.Unauthorized);
+
+ validator.Verify(
+ x => x.ValidateSessionAsync(
+ It.IsAny(),
+ It.IsAny()),
+ Times.AtLeastOnce);
+ }
+
+ // --------------------------------------------------
+ // Valid session -> 200
+ // --------------------------------------------------
+
+ [Fact]
+ public async Task ProtectedEndpoint_WithValidSession_ShouldReturnOk()
+ {
+ var validator = CreateValidator(
+ ClaimsSnapshot.Empty);
+
+ await using var host =
+ await ResourceApiTestHost.CreateAsync(
+ validator.Object);
+
+ AddSession(host.Client);
+
+ var response = await host.Client.GetAsync(
+ "/__tests/resource/authenticated");
+
+ response.StatusCode.Should()
+ .Be(HttpStatusCode.OK);
+ }
+
+ // --------------------------------------------------
+ // Valid session but missing role -> 403
+ // --------------------------------------------------
+
+ [Fact]
+ public async Task AdminEndpoint_WithAuthenticatedNonAdminUser_ShouldReturnForbidden()
+ {
+ var validator = CreateValidator(
+ ClaimsSnapshot.From(
+ (ClaimTypes.Role, "User")));
+
+ await using var host =
+ await ResourceApiTestHost.CreateAsync(
+ validator.Object);
+
+ AddSession(host.Client);
+
+ var response = await host.Client.GetAsync(
+ "/__tests/resource/admin");
+
+ response.StatusCode.Should()
+ .Be(HttpStatusCode.Forbidden);
+ }
+
+ // --------------------------------------------------
+ // Valid Admin -> 200
+ // --------------------------------------------------
+
+ [Fact]
+ public async Task AdminEndpoint_WithAdminRole_ShouldReturnOk()
+ {
+ var validator = CreateValidator(
+ ClaimsSnapshot.From(
+ (ClaimTypes.Role, "Admin")));
+
+ await using var host =
+ await ResourceApiTestHost.CreateAsync(
+ validator.Object);
+
+ AddSession(host.Client);
+
+ var response = await host.Client.GetAsync(
+ "/__tests/resource/admin");
+
+ response.StatusCode.Should()
+ .Be(HttpStatusCode.OK);
+ }
+
+ // --------------------------------------------------
+ // Claims really reach developer's controller
+ // --------------------------------------------------
+
+ [Fact]
+ public async Task AuthenticatedEndpoint_ShouldExposeUltimateAuthIdentityToController()
+ {
+ var validator = CreateValidator(
+ ClaimsSnapshot.From(
+ (ClaimTypes.Role, "Admin"),
+ ("uauth:permission", "products.read.self"),
+ ("uauth:permission", "products.update.admin")));
+
+ await using var host =
+ await ResourceApiTestHost.CreateAsync(
+ validator.Object);
+
+ AddSession(host.Client);
+
+ var response = await host.Client.GetAsync(
+ "/__tests/resource/identity");
+
+ response.StatusCode.Should()
+ .Be(HttpStatusCode.OK);
+
+ var body =
+ await response.Content
+ .ReadFromJsonAsync();
+
+ body.Should().NotBeNull();
+
+ body!.UserId.Should().Be(User.Value);
+
+ body.Roles.Should()
+ .ContainSingle()
+ .Which.Should()
+ .Be("Admin");
+
+ body.Permissions.Should()
+ .BeEquivalentTo(
+ "products.read.self",
+ "products.update.admin");
+ }
+
+ private static Mock CreateValidator(
+ ClaimsSnapshot claims)
+ {
+ var validator = new Mock();
+
+ validator
+ .Setup(x => x.ValidateSessionAsync(
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(
+ SessionValidationResult.Active(
+ tenant: Tenant,
+ userKey: User,
+ sessionId: SessionId,
+ chainId: ChainId,
+ rootId: RootId,
+ claims: claims,
+ authenticatedAt: Now));
+
+ return validator;
+ }
+
+ private static void AddSession(HttpClient client)
+ {
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue(
+ "Bearer",
+ SessionValue);
+ }
+
+ private sealed class IdentityResponse
+ {
+ public string? UserId { get; init; }
+
+ public string[] Roles { get; init; } = [];
+
+ public string[] Permissions { get; init; } = [];
+ }
+}
\ No newline at end of file
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs
new file mode 100644
index 00000000..271e73a1
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs
@@ -0,0 +1,422 @@
+using CodeBeam.UltimateAuth.Authorization.Contracts;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Defaults;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Policies.Abstractions;
+using CodeBeam.UltimateAuth.Server;
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Moq;
+using System.Net;
+using System.Net.Http.Headers;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration.ResourceApi;
+
+public sealed class ResourceApiAuthorizationTests
+{
+ private const string SessionValue =
+ "test-resource-session-000000000000000000000001";
+
+ private static readonly TenantKey Tenant =
+ TenantKey.FromExternal("tenant-1");
+
+ private static readonly UserKey User =
+ UserKey.FromString("user-1");
+
+ private static readonly DateTimeOffset Now =
+ new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
+
+ private static AuthSessionId SessionId =>
+ AuthSessionId.Parse(SessionValue, null);
+
+ private static SessionChainId ChainId =>
+ SessionChainId.From(
+ Guid.Parse("11111111-1111-1111-1111-111111111111"));
+
+ private static SessionRootId RootId =>
+ SessionRootId.From(
+ Guid.Parse("22222222-2222-2222-2222-222222222222"));
+
+ [Fact]
+ public async Task PolicyEndpoint_WithoutCredential_ShouldReturnUnauthorized()
+ {
+ var validator = new Mock();
+
+ await using var host =
+ await ResourceApiTestHost.CreateAsync(validator.Object);
+
+ var response = await host.Client.GetAsync(
+ "/__tests/resource/products/read");
+
+ response.StatusCode.Should()
+ .Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task PolicyEndpoint_WithInvalidSession_ShouldReturnUnauthorized()
+ {
+ var validator = new Mock();
+
+ validator
+ .Setup(x => x.ValidateSessionAsync(
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(
+ SessionValidationResult.Invalid(
+ SessionState.Revoked));
+
+ await using var host =
+ await ResourceApiTestHost.CreateAsync(validator.Object);
+
+ AddSession(host.Client);
+
+ var response = await host.Client.GetAsync(
+ "/__tests/resource/products/read");
+
+ response.StatusCode.Should()
+ .Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task PolicyEndpoint_WhenAuthorityAllows_ShouldReturnOk()
+ {
+ var validator = CreateValidValidator(
+ ("uauth:permission", TestResourceController.ProductsRead));
+
+ var authority = CreateAllowAuthority();
+ var policyProvider = CreatePolicyProvider();
+
+ await using var host =
+ await CreateHostAsync(
+ validator.Object,
+ authority.Object,
+ policyProvider.Object);
+
+ AddSession(host.Client);
+
+ var response = await host.Client.GetAsync(
+ "/__tests/resource/products/read");
+
+ response.StatusCode.Should()
+ .Be(HttpStatusCode.OK);
+
+ authority.Verify(
+ x => x.Decide(
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task PolicyEndpoint_WhenAuthorityDenies_ShouldReturnForbidden()
+ {
+ var validator = CreateValidValidator();
+
+ var authority = CreateDenyAuthority();
+ var policyProvider = CreatePolicyProvider();
+
+ await using var host =
+ await CreateHostAsync(
+ validator.Object,
+ authority.Object,
+ policyProvider.Object);
+
+ AddSession(host.Client);
+
+ var response = await host.Client.GetAsync(
+ "/__tests/resource/products/read");
+
+ response.StatusCode.Should()
+ .Be(HttpStatusCode.Forbidden);
+ }
+
+ [Fact]
+ public async Task ProductsReadPolicy_ShouldBuildExpectedAccessContext()
+ {
+ var validator = CreateValidValidator(
+ ("uauth:permission", TestResourceController.ProductsRead));
+
+ AccessContext? captured = null;
+
+ var authority = new Mock();
+
+ authority
+ .Setup(x => x.Decide(
+ It.IsAny(),
+ It.IsAny>()))
+ .Callback>(
+ (context, _) => captured = context)
+ .Returns(CreateAllowedDecision());
+
+ var policyProvider = CreatePolicyProvider();
+
+ await using var host =
+ await CreateHostAsync(
+ validator.Object,
+ authority.Object,
+ policyProvider.Object);
+
+ AddSession(host.Client);
+
+ var response = await host.Client.GetAsync(
+ "/__tests/resource/products/read");
+
+ response.StatusCode.Should()
+ .Be(HttpStatusCode.OK);
+
+ captured.Should().NotBeNull();
+
+ captured!.Action.Should()
+ .Be(TestResourceController.ProductsRead);
+
+ captured.Resource.Should()
+ .Be("products");
+
+ captured.ActorUserKey.Should()
+ .Be(User);
+
+ captured.ActorTenant.Should()
+ .Be(TenantKey.Single);
+
+ captured.ResourceTenant.Should()
+ .Be(TenantKey.Single);
+
+ captured.IsAuthenticated.Should()
+ .BeTrue();
+ }
+
+ [Fact]
+ public async Task PolicyAuthorization_ShouldEnrichAccessContextWithPermissionsFromClaims()
+ {
+ var validator = CreateValidValidator(
+ ("uauth:permission", TestResourceController.ProductsRead),
+ ("uauth:permission", "orders.read.self"));
+
+ AccessContext? captured = null;
+
+ var authority = new Mock();
+
+ authority
+ .Setup(x => x.Decide(
+ It.IsAny(),
+ It.IsAny>()))
+ .Callback>(
+ (context, _) => captured = context)
+ .Returns(CreateAllowedDecision());
+
+ var policyProvider = CreatePolicyProvider();
+
+ await using var host =
+ await CreateHostAsync(
+ validator.Object,
+ authority.Object,
+ policyProvider.Object);
+
+ AddSession(host.Client);
+
+ var response = await host.Client.GetAsync(
+ "/__tests/resource/products/read");
+
+ response.StatusCode.Should()
+ .Be(HttpStatusCode.OK);
+
+ captured.Should().NotBeNull();
+
+ captured!.Attributes.Should()
+ .ContainKey(UAuthConstants.Access.Permissions);
+
+ captured.Attributes[UAuthConstants.Access.Permissions]
+ .Should()
+ .BeOfType();
+ }
+
+ [Fact]
+ public async Task PolicyAuthorization_ShouldPassEnrichedContextToPolicyProvider()
+ {
+ var validator = CreateValidValidator(
+ ("uauth:permission", TestResourceController.ProductsRead));
+
+ AccessContext? policyContext = null;
+
+ var policyProvider = new Mock();
+
+ policyProvider
+ .Setup(x => x.GetPolicies(
+ It.IsAny()))
+ .Callback(
+ context => policyContext = context)
+ .Returns(Array.Empty());
+
+ var authority = CreateAllowAuthority();
+
+ await using var host =
+ await CreateHostAsync(
+ validator.Object,
+ authority.Object,
+ policyProvider.Object);
+
+ AddSession(host.Client);
+
+ var response = await host.Client.GetAsync(
+ "/__tests/resource/products/read");
+
+ response.StatusCode.Should()
+ .Be(HttpStatusCode.OK);
+
+ policyContext.Should().NotBeNull();
+
+ policyContext!.Attributes.Should()
+ .ContainKey(UAuthConstants.Access.Permissions);
+
+ policyContext.Attributes[UAuthConstants.Access.Permissions]
+ .Should()
+ .BeOfType();
+ }
+
+ [Fact]
+ public async Task ProductsUpdatePolicy_ShouldPreserveFullActionAndResolveProductsResource()
+ {
+ var validator = CreateValidValidator(
+ ("uauth:permission", TestResourceController.ProductsUpdate));
+
+ AccessContext? captured = null;
+
+ var authority = new Mock();
+
+ authority
+ .Setup(x => x.Decide(
+ It.IsAny(),
+ It.IsAny>()))
+ .Callback>(
+ (context, _) => captured = context)
+ .Returns(CreateAllowedDecision());
+
+ var policyProvider = CreatePolicyProvider();
+
+ await using var host =
+ await CreateHostAsync(
+ validator.Object,
+ authority.Object,
+ policyProvider.Object);
+
+ AddSession(host.Client);
+
+ using var request = new HttpRequestMessage(
+ HttpMethod.Put,
+ "/__tests/resource/products/42");
+
+ var response = await host.Client.SendAsync(request);
+
+ response.StatusCode.Should()
+ .Be(HttpStatusCode.OK);
+
+ captured.Should().NotBeNull();
+
+ captured!.Action.Should()
+ .Be(TestResourceController.ProductsUpdate);
+
+ captured.Resource.Should()
+ .Be("products");
+ }
+
+ private static async Task CreateHostAsync(
+ ISessionValidator validator,
+ IAccessAuthority authority,
+ IAccessPolicyProvider policyProvider)
+ {
+ return await ResourceApiTestHost.CreateAsync(
+ validator,
+ services =>
+ {
+ services.AddScoped(
+ _ => authority);
+
+ services.AddScoped(
+ _ => policyProvider);
+ });
+ }
+
+ private static Mock CreatePolicyProvider()
+ {
+ var provider = new Mock();
+
+ provider
+ .Setup(x => x.GetPolicies(
+ It.IsAny()))
+ .Returns(Array.Empty());
+
+ return provider;
+ }
+
+ private static Mock CreateAllowAuthority()
+ {
+ var authority = new Mock();
+
+ authority
+ .Setup(x => x.Decide(
+ It.IsAny(),
+ It.IsAny>()))
+ .Returns(CreateAllowedDecision());
+
+ return authority;
+ }
+
+ private static Mock CreateDenyAuthority()
+ {
+ var authority = new Mock();
+
+ authority
+ .Setup(x => x.Decide(
+ It.IsAny(),
+ It.IsAny>()))
+ .Returns(CreateDeniedDecision());
+
+ return authority;
+ }
+
+ private static Mock CreateValidValidator(
+ params (string Type, string Value)[] claims)
+ {
+ var validator = new Mock();
+
+ var snapshot = claims.Length == 0
+ ? ClaimsSnapshot.Empty
+ : ClaimsSnapshot.From(claims);
+
+ validator
+ .Setup(x => x.ValidateSessionAsync(
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(
+ SessionValidationResult.Active(
+ tenant: Tenant,
+ userKey: User,
+ sessionId: SessionId,
+ chainId: ChainId,
+ rootId: RootId,
+ claims: snapshot,
+ authenticatedAt: Now));
+
+ return validator;
+ }
+
+ private static void AddSession(HttpClient client)
+ {
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue(
+ "Bearer",
+ SessionValue);
+ }
+
+ private static AccessDecision CreateAllowedDecision()
+ {
+ return AccessDecision.Allow();
+ }
+
+ private static AccessDecision CreateDeniedDecision()
+ {
+ return AccessDecision.Deny("missing_permission");
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTenantIsolationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTenantIsolationTests.cs
new file mode 100644
index 00000000..ef23ac53
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTenantIsolationTests.cs
@@ -0,0 +1,264 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Server;
+using FluentAssertions;
+using Moq;
+using System.Net;
+using System.Net.Http.Headers;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration.ResourceApi;
+
+public sealed class ResourceApiTenantIsolationTests
+{
+ private const string SessionValue =
+ "test-resource-session-000000000000000000000001";
+
+ private const string TenantHeader = "X-Tenant-Id";
+
+ private static readonly TenantKey TenantA =
+ TenantKey.FromExternal("tenant-a");
+
+ private static readonly TenantKey TenantB =
+ TenantKey.FromExternal("tenant-b");
+
+ private static readonly UserKey User =
+ UserKey.FromString("user-1");
+
+ private static readonly DateTimeOffset Now =
+ new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
+
+ private static AuthSessionId SessionId =>
+ AuthSessionId.Parse(SessionValue, null);
+
+ private static SessionChainId ChainId =>
+ SessionChainId.From(
+ Guid.Parse("11111111-1111-1111-1111-111111111111"));
+
+ private static SessionRootId RootId =>
+ SessionRootId.From(
+ Guid.Parse("22222222-2222-2222-2222-222222222222"));
+
+ [Fact]
+ public async Task RequestTenant_ShouldBePassedToSessionValidation()
+ {
+ SessionValidationContext? captured = null;
+
+ var validator = new Mock();
+
+ validator
+ .Setup(x => x.ValidateSessionAsync(
+ It.IsAny(),
+ It.IsAny()))
+ .Callback(
+ (context, _) => captured = context)
+ .ReturnsAsync(CreateActiveResult(TenantA));
+
+ await using var host =
+ await CreateMultiTenantHostAsync(validator.Object);
+
+ AddSession(host.Client);
+ AddTenant(host.Client, "tenant-a");
+
+ var response = await host.Client.GetAsync(
+ "/__tests/resource/authenticated");
+
+ response.StatusCode.Should()
+ .Be(HttpStatusCode.OK);
+
+ captured.Should().NotBeNull();
+
+ captured!.Tenant.Should()
+ .Be(TenantA);
+ }
+
+ [Fact]
+ public async Task TenantA_WithTenantASession_ShouldAuthenticate()
+ {
+ var validator = new Mock();
+
+ validator
+ .Setup(x => x.ValidateSessionAsync(
+ It.Is(
+ context => context.Tenant == TenantA),
+ It.IsAny()))
+ .ReturnsAsync(CreateActiveResult(TenantA));
+
+ await using var host =
+ await CreateMultiTenantHostAsync(validator.Object);
+
+ AddSession(host.Client);
+ AddTenant(host.Client, "tenant-a");
+
+ var response = await host.Client.GetAsync(
+ "/__tests/resource/authenticated");
+
+ response.StatusCode.Should()
+ .Be(HttpStatusCode.OK);
+ }
+
+ [Fact]
+ public async Task TenantB_WithTenantASession_ShouldNotAuthenticate()
+ {
+ var validator = new Mock();
+
+ validator
+ .Setup(x => x.ValidateSessionAsync(
+ It.Is(
+ context => context.Tenant == TenantB),
+ It.IsAny()))
+ .ReturnsAsync(
+ SessionValidationResult.Invalid(
+ SessionState.NotFound));
+
+ await using var host =
+ await CreateMultiTenantHostAsync(validator.Object);
+
+ AddSession(host.Client);
+ AddTenant(host.Client, "tenant-b");
+
+ var response = await host.Client.GetAsync(
+ "/__tests/resource/authenticated");
+
+ response.StatusCode.Should()
+ .Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task SameSessionId_ShouldRemainTenantScoped()
+ {
+ var validator = new Mock();
+
+ validator
+ .Setup(x => x.ValidateSessionAsync(
+ It.Is(
+ context =>
+ context.Tenant == TenantA &&
+ context.SessionId == SessionId),
+ It.IsAny()))
+ .ReturnsAsync(CreateActiveResult(TenantA));
+
+ validator
+ .Setup(x => x.ValidateSessionAsync(
+ It.Is(
+ context =>
+ context.Tenant == TenantB &&
+ context.SessionId == SessionId),
+ It.IsAny()))
+ .ReturnsAsync(
+ SessionValidationResult.Invalid(
+ SessionState.NotFound));
+
+ await using var host =
+ await CreateMultiTenantHostAsync(validator.Object);
+
+ AddSession(host.Client);
+ AddTenant(host.Client, "tenant-a");
+
+ var tenantAResponse = await host.Client.GetAsync(
+ "/__tests/resource/authenticated");
+
+ tenantAResponse.StatusCode.Should()
+ .Be(HttpStatusCode.OK);
+
+ host.Client.DefaultRequestHeaders.Remove(TenantHeader);
+ AddTenant(host.Client, "tenant-b");
+
+ var tenantBResponse = await host.Client.GetAsync(
+ "/__tests/resource/authenticated");
+
+ tenantBResponse.StatusCode.Should()
+ .Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task ResolvedTenant_ShouldBePreservedInValidationContext()
+ {
+ var observedTenants = new List();
+
+ var validator = new Mock();
+
+ validator
+ .Setup(x => x.ValidateSessionAsync(
+ It.IsAny(),
+ It.IsAny()))
+ .Callback(
+ (context, _) =>
+ {
+ observedTenants.Add(context.Tenant);
+ })
+ .ReturnsAsync(
+ (SessionValidationContext context, CancellationToken _) =>
+ CreateActiveResult(context.Tenant));
+
+ await using var host =
+ await CreateMultiTenantHostAsync(validator.Object);
+
+ AddSession(host.Client);
+
+ AddTenant(host.Client, "tenant-a");
+
+ var tenantAResponse = await host.Client.GetAsync(
+ "/__tests/resource/authenticated");
+
+ host.Client.DefaultRequestHeaders.Remove(TenantHeader);
+
+ AddTenant(host.Client, "tenant-b");
+
+ var tenantBResponse = await host.Client.GetAsync(
+ "/__tests/resource/authenticated");
+
+ tenantAResponse.StatusCode.Should()
+ .Be(HttpStatusCode.OK);
+
+ tenantBResponse.StatusCode.Should()
+ .Be(HttpStatusCode.OK);
+
+ observedTenants.Should()
+ .Contain(TenantA);
+
+ observedTenants.Should()
+ .Contain(TenantB);
+ }
+
+ private static SessionValidationResult CreateActiveResult(
+ TenantKey tenant)
+ {
+ return SessionValidationResult.Active(
+ tenant: tenant,
+ userKey: User,
+ sessionId: SessionId,
+ chainId: ChainId,
+ rootId: RootId,
+ claims: ClaimsSnapshot.Empty,
+ authenticatedAt: Now);
+ }
+
+ private static void AddSession(HttpClient client)
+ {
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue(
+ "Bearer",
+ SessionValue);
+ }
+
+ private static void AddTenant(
+ HttpClient client,
+ string tenant)
+ {
+ client.DefaultRequestHeaders.Add(
+ TenantHeader,
+ tenant);
+ }
+
+ private static async Task CreateMultiTenantHostAsync(ISessionValidator validator)
+ {
+ return await ResourceApiTestHost.CreateAsync(validator,
+ configureResourceApi: options =>
+ {
+ options.MultiTenant.Enabled = true;
+ options.MultiTenant.EnableHeader = true;
+ options.MultiTenant.HeaderName = TenantHeader;
+ });
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTestHost.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTestHost.cs
new file mode 100644
index 00000000..db68a4b1
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTestHost.cs
@@ -0,0 +1,62 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Server;
+using CodeBeam.UltimateAuth.Server.Extensions;
+using CodeBeam.UltimateAuth.Server.Options;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration.ResourceApi;
+
+internal sealed class ResourceApiTestHost : IAsyncDisposable
+{
+ private readonly WebApplication _app;
+
+ public HttpClient Client { get; }
+
+ private ResourceApiTestHost(
+ WebApplication app,
+ HttpClient client)
+ {
+ _app = app;
+ Client = client;
+ }
+
+ public static async Task CreateAsync(ISessionValidator sessionValidator, Action? configureServices = null, Action? configureResourceApi = null)
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddControllers().AddApplicationPart(typeof(TestResourceController).Assembly);
+
+ builder.Services.AddUltimateAuthResourceApi(options =>
+ {
+ options.UAuthHubBaseUrl = "https://uauth.test";
+ configureResourceApi?.Invoke(options);
+ });
+
+ builder.Services.AddScoped(_ => sessionValidator);
+
+ configureServices?.Invoke(builder.Services);
+
+ var app = builder.Build();
+
+ app.UseUltimateAuthResourceApiWithAspNetCore();
+
+ app.MapControllers();
+
+ await app.StartAsync();
+
+ return new ResourceApiTestHost(
+ app,
+ app.GetTestClient());
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ Client.Dispose();
+
+ await _app.StopAsync();
+ await _app.DisposeAsync();
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/TestResourceController.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/TestResourceController.cs
new file mode 100644
index 00000000..bef6205b
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/TestResourceController.cs
@@ -0,0 +1,66 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Defaults;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using System.Security.Claims;
+
+namespace CodeBeam.UltimateAuth.Tests.Integration.ResourceApi;
+
+[ApiController]
+[Route("__tests/resource")]
+public sealed class TestResourceController : ControllerBase
+{
+ public const string ProductsRead = "products.read.self";
+ public const string ProductsUpdate = "products.update.admin";
+
+ [HttpGet("anonymous")]
+ [AllowAnonymous]
+ public IActionResult Anonymous()
+ {
+ return Ok();
+ }
+
+ [HttpGet("authenticated")]
+ [Authorize]
+ public IActionResult Authenticated()
+ {
+ return Ok();
+ }
+
+ [HttpGet("admin")]
+ [Authorize(Roles = "Admin")]
+ public IActionResult Admin()
+ {
+ return Ok();
+ }
+
+ [HttpGet("identity")]
+ [Authorize]
+ public IActionResult Identity()
+ {
+ return Ok(new
+ {
+ UserId = User.FindFirstValue(ClaimTypes.NameIdentifier),
+ Roles = User.FindAll(ClaimTypes.Role)
+ .Select(x => x.Value)
+ .ToArray(),
+ Permissions = User.FindAll("uauth:permission")
+ .Select(x => x.Value)
+ .ToArray()
+ });
+ }
+
+ [HttpGet("products/read")]
+ [Authorize(Policy = ProductsRead)]
+ public IActionResult ReadProducts()
+ {
+ return Ok();
+ }
+
+ [HttpPut("products/{id:int}")]
+ [Authorize(Policy = ProductsUpdate)]
+ public IActionResult UpdateProduct(int id)
+ {
+ return Ok(new { Id = id });
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UAuthHubSampleSmokeTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UAuthHubSampleSmokeTests.cs
new file mode 100644
index 00000000..f224fa9a
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UAuthHubSampleSmokeTests.cs
@@ -0,0 +1,29 @@
+using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure;
+using FluentAssertions;
+using Microsoft.AspNetCore.Mvc.Testing;
+
+public sealed class UAuthHubSampleSmokeTests : IClassFixture