1683 lines
53 KiB
Markdown
1683 lines
53 KiB
Markdown
# SSO Integration Architecture
|
|
|
|
## Table of Contents
|
|
|
|
1. [SSO Architecture Overview](#sso-architecture-overview)
|
|
2. [SsoConfiguration Value Object](#ssoconfiguration-value-object)
|
|
3. [OIDC Integration Implementation](#oidc-integration-implementation)
|
|
4. [SAML 2.0 Integration Implementation](#saml-20-integration-implementation)
|
|
5. [SSO Login Flow](#sso-login-flow)
|
|
6. [User Auto-Provisioning](#user-auto-provisioning)
|
|
7. [SSO Configuration Management](#sso-configuration-management)
|
|
8. [Frontend Integration](#frontend-integration)
|
|
9. [Security Considerations](#security-considerations)
|
|
10. [Testing](#testing)
|
|
|
|
---
|
|
|
|
## SSO Architecture Overview
|
|
|
|
### Supported Protocols
|
|
|
|
ColaFlow supports industry-standard SSO protocols:
|
|
|
|
- **OIDC (OpenID Connect)**: For Azure AD, Google Workspace, Okta
|
|
- **OAuth 2.0**: Authorization delegation
|
|
- **SAML 2.0**: Enterprise IdP integration
|
|
|
|
### Supported Identity Providers
|
|
|
|
1. **Azure AD** (Microsoft Entra ID) - OIDC
|
|
2. **Google Workspace** - OIDC
|
|
3. **Okta** - OIDC
|
|
4. **Generic SAML 2.0** - Any SAML-compliant IdP
|
|
|
|
### SSO + Local Auth Coexistence
|
|
|
|
```mermaid
|
|
graph LR
|
|
A[User] --> B{Login Method}
|
|
B -->|Local Auth| C[Email + Password]
|
|
B -->|SSO| D[Select IdP]
|
|
C --> E[JWT Token]
|
|
D --> F[Redirect to IdP]
|
|
F --> G[IdP Authenticates]
|
|
G --> H[Callback to ColaFlow]
|
|
H --> I[Validate SAML/OIDC Response]
|
|
I --> J[Auto-Provision or Update User]
|
|
J --> E
|
|
```
|
|
|
|
### Architecture Components
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ Frontend (Next.js) │
|
|
│ - Login page with SSO buttons │
|
|
│ - SSO callback handler │
|
|
└────────────────────────┬────────────────────────────────────┘
|
|
│
|
|
┌────────────────────────▼────────────────────────────────────┐
|
|
│ API Layer (ASP.NET) │
|
|
│ - /api/auth/sso/initiate │
|
|
│ - /api/auth/sso/callback │
|
|
└────────────────────────┬────────────────────────────────────┘
|
|
│
|
|
┌────────────────────────▼────────────────────────────────────┐
|
|
│ Authentication Middleware │
|
|
│ - Microsoft.AspNetCore.Authentication.OpenIdConnect │
|
|
│ - Sustainsys.Saml2.AspNetCore │
|
|
└────────────────────────┬────────────────────────────────────┘
|
|
│
|
|
┌────────────────────────▼────────────────────────────────────┐
|
|
│ Application Layer │
|
|
│ - LoginWithSsoCommand │
|
|
│ - HandleSsoCallbackCommand │
|
|
│ - ConfigureSsoCommand │
|
|
└────────────────────────┬────────────────────────────────────┘
|
|
│
|
|
┌────────────────────────▼────────────────────────────────────┐
|
|
│ Domain Layer │
|
|
│ - User.CreateFromSso() │
|
|
│ - User.UpdateSsoProfile() │
|
|
│ - Tenant.ConfigureSso() │
|
|
└────────────────────────┬────────────────────────────────────┘
|
|
│
|
|
┌────────────────────────▼────────────────────────────────────┐
|
|
│ Infrastructure Layer │
|
|
│ - SsoService: Claims mapping │
|
|
│ - JwtService: Generate JWT with SSO claims │
|
|
└──────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## SsoConfiguration Value Object
|
|
|
|
This value object was already defined in the multi-tenancy architecture document. Here's the complete implementation with additional helper methods:
|
|
|
|
**File**: `src/ColaFlow.Domain/Aggregates/TenantAggregate/ValueObjects/SsoConfiguration.cs`
|
|
|
|
```csharp
|
|
using ColaFlow.Domain.Common;
|
|
|
|
namespace ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
public sealed class SsoConfiguration : ValueObject
|
|
{
|
|
public SsoProvider Provider { get; }
|
|
public string Authority { get; }
|
|
public string ClientId { get; }
|
|
public string ClientSecret { get; } // Encrypted in database
|
|
public string? MetadataUrl { get; }
|
|
|
|
// SAML-specific
|
|
public string? EntityId { get; }
|
|
public string? SignOnUrl { get; }
|
|
public string? Certificate { get; }
|
|
|
|
// Additional settings
|
|
public bool AutoProvisionUsers { get; private init; }
|
|
public bool RequireEmailVerification { get; private init; }
|
|
public string[]? AllowedDomains { get; private init; }
|
|
|
|
private SsoConfiguration(
|
|
SsoProvider provider,
|
|
string authority,
|
|
string clientId,
|
|
string clientSecret,
|
|
string? metadataUrl = null,
|
|
string? entityId = null,
|
|
string? signOnUrl = null,
|
|
string? certificate = null,
|
|
bool autoProvisionUsers = true,
|
|
bool requireEmailVerification = false,
|
|
string[]? allowedDomains = null)
|
|
{
|
|
Provider = provider;
|
|
Authority = authority;
|
|
ClientId = clientId;
|
|
ClientSecret = clientSecret;
|
|
MetadataUrl = metadataUrl;
|
|
EntityId = entityId;
|
|
SignOnUrl = signOnUrl;
|
|
Certificate = certificate;
|
|
AutoProvisionUsers = autoProvisionUsers;
|
|
RequireEmailVerification = requireEmailVerification;
|
|
AllowedDomains = allowedDomains;
|
|
}
|
|
|
|
public static SsoConfiguration CreateOidc(
|
|
SsoProvider provider,
|
|
string authority,
|
|
string clientId,
|
|
string clientSecret,
|
|
string? metadataUrl = null,
|
|
bool autoProvisionUsers = true,
|
|
string[]? allowedDomains = null)
|
|
{
|
|
if (provider == SsoProvider.GenericSaml)
|
|
throw new ArgumentException("Use CreateSaml for SAML configuration");
|
|
|
|
ValidateOidcParameters(authority, clientId, clientSecret);
|
|
|
|
return new SsoConfiguration(
|
|
provider,
|
|
authority,
|
|
clientId,
|
|
clientSecret,
|
|
metadataUrl,
|
|
autoProvisionUsers: autoProvisionUsers,
|
|
allowedDomains: allowedDomains);
|
|
}
|
|
|
|
public static SsoConfiguration CreateSaml(
|
|
string entityId,
|
|
string signOnUrl,
|
|
string certificate,
|
|
string? metadataUrl = null,
|
|
bool autoProvisionUsers = true,
|
|
string[]? allowedDomains = null)
|
|
{
|
|
ValidateSamlParameters(entityId, signOnUrl, certificate);
|
|
|
|
return new SsoConfiguration(
|
|
SsoProvider.GenericSaml,
|
|
signOnUrl,
|
|
entityId,
|
|
string.Empty, // No client secret for SAML
|
|
metadataUrl,
|
|
entityId,
|
|
signOnUrl,
|
|
certificate,
|
|
autoProvisionUsers,
|
|
allowedDomains: allowedDomains);
|
|
}
|
|
|
|
public bool IsEmailDomainAllowed(string email)
|
|
{
|
|
if (AllowedDomains is null || AllowedDomains.Length == 0)
|
|
return true;
|
|
|
|
var domain = email.Split('@').Last().ToLowerInvariant();
|
|
return AllowedDomains.Any(d => d.ToLowerInvariant() == domain);
|
|
}
|
|
|
|
private static void ValidateOidcParameters(string authority, string clientId, string clientSecret)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(authority))
|
|
throw new ArgumentException("Authority is required", nameof(authority));
|
|
|
|
if (!Uri.TryCreate(authority, UriKind.Absolute, out _))
|
|
throw new ArgumentException("Authority must be a valid URL", nameof(authority));
|
|
|
|
if (string.IsNullOrWhiteSpace(clientId))
|
|
throw new ArgumentException("Client ID is required", nameof(clientId));
|
|
|
|
if (string.IsNullOrWhiteSpace(clientSecret))
|
|
throw new ArgumentException("Client secret is required", nameof(clientSecret));
|
|
}
|
|
|
|
private static void ValidateSamlParameters(string entityId, string signOnUrl, string certificate)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(entityId))
|
|
throw new ArgumentException("Entity ID is required", nameof(entityId));
|
|
|
|
if (string.IsNullOrWhiteSpace(signOnUrl))
|
|
throw new ArgumentException("Sign-on URL is required", nameof(signOnUrl));
|
|
|
|
if (!Uri.TryCreate(signOnUrl, UriKind.Absolute, out _))
|
|
throw new ArgumentException("Sign-on URL must be a valid URL", nameof(signOnUrl));
|
|
|
|
if (string.IsNullOrWhiteSpace(certificate))
|
|
throw new ArgumentException("Certificate is required", nameof(certificate));
|
|
}
|
|
|
|
protected override IEnumerable<object> GetEqualityComponents()
|
|
{
|
|
yield return Provider;
|
|
yield return Authority;
|
|
yield return ClientId;
|
|
yield return EntityId ?? string.Empty;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## OIDC Integration Implementation
|
|
|
|
### NuGet Packages
|
|
|
|
```xml
|
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.0" />
|
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="9.0.0" />
|
|
<PackageReference Include="Microsoft.Identity.Web" Version="3.0.0" />
|
|
```
|
|
|
|
### Program.cs Configuration
|
|
|
|
**File**: `src/ColaFlow.API/Program.cs`
|
|
|
|
```csharp
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
|
using Microsoft.Identity.Web;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// Add authentication services
|
|
builder.Services.AddAuthentication(options =>
|
|
{
|
|
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
|
|
})
|
|
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
|
|
{
|
|
options.Cookie.Name = "ColaFlow.Auth";
|
|
options.Cookie.HttpOnly = true;
|
|
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
|
options.ExpireTimeSpan = TimeSpan.FromHours(8);
|
|
options.SlidingExpiration = true;
|
|
})
|
|
.AddOpenIdConnect("AzureAD", options =>
|
|
{
|
|
// Configuration loaded dynamically per tenant
|
|
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
options.ResponseType = "code";
|
|
options.UsePkce = true;
|
|
options.SaveTokens = true;
|
|
|
|
options.Scope.Clear();
|
|
options.Scope.Add("openid");
|
|
options.Scope.Add("profile");
|
|
options.Scope.Add("email");
|
|
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidateAudience = true,
|
|
ValidateLifetime = true,
|
|
ValidateIssuerSigningKey = true
|
|
};
|
|
|
|
// Claims mapping
|
|
options.ClaimActions.MapJsonKey("email", "email");
|
|
options.ClaimActions.MapJsonKey("name", "name");
|
|
options.ClaimActions.MapJsonKey("given_name", "given_name");
|
|
options.ClaimActions.MapJsonKey("family_name", "family_name");
|
|
})
|
|
.AddOpenIdConnect("Google", options =>
|
|
{
|
|
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
options.ResponseType = "code";
|
|
options.UsePkce = true;
|
|
options.SaveTokens = true;
|
|
|
|
options.Scope.Clear();
|
|
options.Scope.Add("openid");
|
|
options.Scope.Add("profile");
|
|
options.Scope.Add("email");
|
|
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidateAudience = true,
|
|
ValidateLifetime = true,
|
|
ValidateIssuerSigningKey = true
|
|
};
|
|
|
|
options.ClaimActions.MapJsonKey("email", "email");
|
|
options.ClaimActions.MapJsonKey("name", "name");
|
|
options.ClaimActions.MapJsonKey("picture", "picture");
|
|
})
|
|
.AddOpenIdConnect("Okta", options =>
|
|
{
|
|
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
options.ResponseType = "code";
|
|
options.UsePkce = true;
|
|
options.SaveTokens = true;
|
|
|
|
options.Scope.Clear();
|
|
options.Scope.Add("openid");
|
|
options.Scope.Add("profile");
|
|
options.Scope.Add("email");
|
|
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidateAudience = true,
|
|
ValidateLifetime = true,
|
|
ValidateIssuerSigningKey = true
|
|
};
|
|
|
|
options.ClaimActions.MapJsonKey("email", "email");
|
|
options.ClaimActions.MapJsonKey("name", "name");
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
app.UseHttpsRedirection();
|
|
app.UseTenantResolution(); // MUST come before authentication
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.MapControllers();
|
|
|
|
app.Run();
|
|
```
|
|
|
|
### Dynamic OIDC Configuration Service
|
|
|
|
**File**: `src/ColaFlow.Infrastructure/Services/DynamicOidcConfigurationService.cs`
|
|
|
|
```csharp
|
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ColaFlow.Infrastructure.Persistence;
|
|
using ColaFlow.Application.Common.Interfaces;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate;
|
|
|
|
namespace ColaFlow.Infrastructure.Services;
|
|
|
|
/// <summary>
|
|
/// Dynamically configures OIDC options based on tenant SSO configuration
|
|
/// </summary>
|
|
public sealed class DynamicOidcConfigurationService : IPostConfigureOptions<OpenIdConnectOptions>
|
|
{
|
|
private readonly ITenantContext _tenantContext;
|
|
private readonly ApplicationDbContext _context;
|
|
private readonly ILogger<DynamicOidcConfigurationService> _logger;
|
|
|
|
public DynamicOidcConfigurationService(
|
|
ITenantContext tenantContext,
|
|
ApplicationDbContext context,
|
|
ILogger<DynamicOidcConfigurationService> logger)
|
|
{
|
|
_tenantContext = tenantContext;
|
|
_context = context;
|
|
_logger = logger;
|
|
}
|
|
|
|
public void PostConfigure(string? name, OpenIdConnectOptions options)
|
|
{
|
|
if (name is null)
|
|
return;
|
|
|
|
try
|
|
{
|
|
// Load tenant SSO configuration
|
|
var tenant = _context.Tenants
|
|
.IgnoreQueryFilters()
|
|
.FirstOrDefault(t => t.Id == _tenantContext.CurrentTenantId);
|
|
|
|
if (tenant?.SsoConfig is null)
|
|
{
|
|
_logger.LogWarning("Tenant {TenantId} has no SSO configuration", _tenantContext.CurrentTenantId);
|
|
return;
|
|
}
|
|
|
|
var ssoConfig = tenant.SsoConfig;
|
|
|
|
// Map provider to scheme name
|
|
if (!IsMatchingProvider(name, ssoConfig.Provider))
|
|
return;
|
|
|
|
// Configure OIDC options
|
|
options.Authority = ssoConfig.Authority;
|
|
options.ClientId = ssoConfig.ClientId;
|
|
options.ClientSecret = ssoConfig.ClientSecret; // TODO: Decrypt
|
|
|
|
if (!string.IsNullOrEmpty(ssoConfig.MetadataUrl))
|
|
{
|
|
options.MetadataAddress = ssoConfig.MetadataUrl;
|
|
}
|
|
|
|
// Set callback paths
|
|
options.CallbackPath = "/api/auth/sso/callback";
|
|
options.SignedOutCallbackPath = "/api/auth/sso/signout-callback";
|
|
|
|
_logger.LogInformation("Configured OIDC for tenant {TenantId} with provider {Provider}",
|
|
_tenantContext.CurrentTenantId, ssoConfig.Provider);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error configuring OIDC for tenant {TenantId}", _tenantContext.CurrentTenantId);
|
|
}
|
|
}
|
|
|
|
private static bool IsMatchingProvider(string schemeName, SsoProvider provider) => (schemeName, provider) switch
|
|
{
|
|
("AzureAD", SsoProvider.AzureAD) => true,
|
|
("Google", SsoProvider.Google) => true,
|
|
("Okta", SsoProvider.Okta) => true,
|
|
_ => false
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## SAML 2.0 Integration Implementation
|
|
|
|
### NuGet Packages
|
|
|
|
```xml
|
|
<PackageReference Include="Sustainsys.Saml2.AspNetCore" Version="2.10.0" />
|
|
```
|
|
|
|
### Program.cs Configuration
|
|
|
|
**File**: `src/ColaFlow.API/Program.cs` (Add to authentication section)
|
|
|
|
```csharp
|
|
using Sustainsys.Saml2;
|
|
using Sustainsys.Saml2.AspNetCore;
|
|
|
|
builder.Services.AddAuthentication()
|
|
// ... existing OIDC configurations
|
|
.AddSaml2("SAML", options =>
|
|
{
|
|
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
options.SPOptions.EntityId = new EntityId("https://colaflow.com/saml");
|
|
options.SPOptions.ReturnUrl = new Uri("https://colaflow.com/api/auth/sso/callback");
|
|
|
|
// Service Provider signing certificate
|
|
options.SPOptions.ServiceCertificates.Add(new X509Certificate2("saml-sp.pfx", "password"));
|
|
|
|
// AuthnRequest settings
|
|
options.SPOptions.AuthenticateRequestSigningBehavior = SigningBehavior.Always;
|
|
|
|
// Dynamic IdP configuration (loaded per tenant)
|
|
options.SPOptions.Compatibility.UnpackEntitiesDescriptorInIdentityProviderMetadata = true;
|
|
});
|
|
```
|
|
|
|
### Dynamic SAML Configuration Service
|
|
|
|
**File**: `src/ColaFlow.Infrastructure/Services/DynamicSamlConfigurationService.cs`
|
|
|
|
```csharp
|
|
using Sustainsys.Saml2;
|
|
using Sustainsys.Saml2.AspNetCore;
|
|
using Sustainsys.Saml2.Configuration;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using ColaFlow.Infrastructure.Persistence;
|
|
using ColaFlow.Application.Common.Interfaces;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate;
|
|
|
|
namespace ColaFlow.Infrastructure.Services;
|
|
|
|
/// <summary>
|
|
/// Dynamically configures SAML 2.0 options based on tenant SSO configuration
|
|
/// </summary>
|
|
public sealed class DynamicSamlConfigurationService : IPostConfigureOptions<Saml2Options>
|
|
{
|
|
private readonly ITenantContext _tenantContext;
|
|
private readonly ApplicationDbContext _context;
|
|
private readonly ILogger<DynamicSamlConfigurationService> _logger;
|
|
|
|
public DynamicSamlConfigurationService(
|
|
ITenantContext tenantContext,
|
|
ApplicationDbContext context,
|
|
ILogger<DynamicSamlConfigurationService> logger)
|
|
{
|
|
_tenantContext = tenantContext;
|
|
_context = context;
|
|
_logger = logger;
|
|
}
|
|
|
|
public void PostConfigure(string? name, Saml2Options options)
|
|
{
|
|
try
|
|
{
|
|
// Load tenant SSO configuration
|
|
var tenant = _context.Tenants
|
|
.IgnoreQueryFilters()
|
|
.FirstOrDefault(t => t.Id == _tenantContext.CurrentTenantId);
|
|
|
|
if (tenant?.SsoConfig is null || tenant.SsoConfig.Provider != SsoProvider.GenericSaml)
|
|
{
|
|
_logger.LogWarning("Tenant {TenantId} has no SAML configuration", _tenantContext.CurrentTenantId);
|
|
return;
|
|
}
|
|
|
|
var ssoConfig = tenant.SsoConfig;
|
|
|
|
// Configure Identity Provider
|
|
var idp = new IdentityProvider(new EntityId(ssoConfig.EntityId!), options.SPOptions)
|
|
{
|
|
SingleSignOnServiceUrl = new Uri(ssoConfig.SignOnUrl!),
|
|
Binding = Saml2BindingType.HttpRedirect
|
|
};
|
|
|
|
// Add signing certificate
|
|
var certBytes = Convert.FromBase64String(ssoConfig.Certificate!);
|
|
var cert = new X509Certificate2(certBytes);
|
|
idp.SigningKeys.AddConfiguredKey(cert);
|
|
|
|
// Metadata URL (optional)
|
|
if (!string.IsNullOrEmpty(ssoConfig.MetadataUrl))
|
|
{
|
|
idp.MetadataLocation = ssoConfig.MetadataUrl;
|
|
idp.LoadMetadata = true;
|
|
}
|
|
|
|
options.IdentityProviders.Add(idp);
|
|
|
|
_logger.LogInformation("Configured SAML for tenant {TenantId}", _tenantContext.CurrentTenantId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error configuring SAML for tenant {TenantId}", _tenantContext.CurrentTenantId);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## SSO Login Flow
|
|
|
|
### Sequence Diagram
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant U as User
|
|
participant FE as Frontend
|
|
participant API as ColaFlow API
|
|
participant IdP as Identity Provider
|
|
participant DB as Database
|
|
|
|
U->>FE: Click "Login with Azure AD"
|
|
FE->>API: POST /api/auth/sso/initiate
|
|
Note over API: Resolve tenant from subdomain
|
|
API->>DB: Load tenant SSO config
|
|
DB-->>API: SsoConfiguration
|
|
API-->>FE: Redirect URL to IdP
|
|
FE->>IdP: Redirect with state parameter
|
|
IdP->>U: Show IdP login page
|
|
U->>IdP: Enter credentials
|
|
IdP->>API: Callback with SAML/OIDC response
|
|
API->>API: Validate signature & claims
|
|
API->>DB: Find or create user
|
|
DB-->>API: User entity
|
|
API->>API: Generate JWT token
|
|
API-->>FE: JWT token + user info
|
|
FE->>FE: Store token in localStorage
|
|
FE-->>U: Redirect to dashboard
|
|
```
|
|
|
|
### Initiate SSO Command
|
|
|
|
**File**: `src/ColaFlow.Application/Auth/Commands/InitiateSso/InitiateSsoCommand.cs`
|
|
|
|
```csharp
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate;
|
|
|
|
namespace ColaFlow.Application.Auth.Commands.InitiateSso;
|
|
|
|
public sealed record InitiateSsoCommand(
|
|
SsoProvider Provider,
|
|
string? ReturnUrl = null) : IRequest<InitiateSsoResult>;
|
|
|
|
public sealed record InitiateSsoResult(string RedirectUrl, string State);
|
|
```
|
|
|
|
**File**: `src/ColaFlow.Application/Auth/Commands/InitiateSso/InitiateSsoCommandHandler.cs`
|
|
|
|
```csharp
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ColaFlow.Application.Common.Interfaces;
|
|
using ColaFlow.Infrastructure.Persistence;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate;
|
|
|
|
namespace ColaFlow.Application.Auth.Commands.InitiateSso;
|
|
|
|
public sealed class InitiateSsoCommandHandler : IRequestHandler<InitiateSsoCommand, InitiateSsoResult>
|
|
{
|
|
private readonly ITenantContext _tenantContext;
|
|
private readonly ApplicationDbContext _context;
|
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
|
private readonly ILogger<InitiateSsoCommandHandler> _logger;
|
|
|
|
public InitiateSsoCommandHandler(
|
|
ITenantContext tenantContext,
|
|
ApplicationDbContext context,
|
|
IHttpContextAccessor httpContextAccessor,
|
|
ILogger<InitiateSsoCommandHandler> logger)
|
|
{
|
|
_tenantContext = tenantContext;
|
|
_context = context;
|
|
_httpContextAccessor = httpContextAccessor;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<InitiateSsoResult> Handle(InitiateSsoCommand request, CancellationToken cancellationToken)
|
|
{
|
|
// 1. Load tenant SSO configuration
|
|
var tenant = await _context.Tenants
|
|
.IgnoreQueryFilters()
|
|
.FirstOrDefaultAsync(t => t.Id == _tenantContext.CurrentTenantId, cancellationToken);
|
|
|
|
if (tenant?.SsoConfig is null)
|
|
throw new InvalidOperationException("SSO is not configured for this tenant");
|
|
|
|
if (tenant.SsoConfig.Provider != request.Provider)
|
|
throw new InvalidOperationException($"Provider {request.Provider} is not configured for this tenant");
|
|
|
|
// 2. Generate state parameter (CSRF protection)
|
|
var state = GenerateSecureState();
|
|
|
|
// 3. Store state in session/cache
|
|
var httpContext = _httpContextAccessor.HttpContext!;
|
|
httpContext.Session.SetString("SsoState", state);
|
|
httpContext.Session.SetString("SsoReturnUrl", request.ReturnUrl ?? "/");
|
|
|
|
// 4. Build authentication properties
|
|
var authProperties = new AuthenticationProperties
|
|
{
|
|
RedirectUri = "/api/auth/sso/callback",
|
|
Items =
|
|
{
|
|
{ "state", state },
|
|
{ "tenant_id", _tenantContext.CurrentTenantId.Value.ToString() },
|
|
{ "provider", request.Provider.ToString() }
|
|
}
|
|
};
|
|
|
|
// 5. Get the scheme name
|
|
var schemeName = GetSchemeName(request.Provider);
|
|
|
|
// 6. Challenge the authentication scheme
|
|
await httpContext.ChallengeAsync(schemeName, authProperties);
|
|
|
|
_logger.LogInformation("Initiated SSO for tenant {TenantId} with provider {Provider}",
|
|
_tenantContext.CurrentTenantId, request.Provider);
|
|
|
|
// Return redirect URL (extracted from challenge)
|
|
var redirectUrl = authProperties.RedirectUri!;
|
|
return new InitiateSsoResult(redirectUrl, state);
|
|
}
|
|
|
|
private static string GenerateSecureState()
|
|
{
|
|
var bytes = new byte[32];
|
|
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
|
|
rng.GetBytes(bytes);
|
|
return Convert.ToBase64String(bytes);
|
|
}
|
|
|
|
private static string GetSchemeName(SsoProvider provider) => provider switch
|
|
{
|
|
SsoProvider.AzureAD => "AzureAD",
|
|
SsoProvider.Google => "Google",
|
|
SsoProvider.Okta => "Okta",
|
|
SsoProvider.GenericSaml => "SAML",
|
|
_ => throw new ArgumentOutOfRangeException(nameof(provider))
|
|
};
|
|
}
|
|
```
|
|
|
|
### Handle SSO Callback Command
|
|
|
|
**File**: `src/ColaFlow.Application/Auth/Commands/HandleSsoCallback/HandleSsoCallbackCommand.cs`
|
|
|
|
```csharp
|
|
using System.Security.Claims;
|
|
|
|
namespace ColaFlow.Application.Auth.Commands.HandleSsoCallback;
|
|
|
|
public sealed record HandleSsoCallbackCommand(
|
|
ClaimsPrincipal Principal,
|
|
string State) : IRequest<HandleSsoCallbackResult>;
|
|
|
|
public sealed record HandleSsoCallbackResult(
|
|
string AccessToken,
|
|
string RefreshToken,
|
|
UserDto User);
|
|
|
|
public sealed record UserDto(
|
|
Guid Id,
|
|
string Email,
|
|
string FullName,
|
|
string? AvatarUrl);
|
|
```
|
|
|
|
**File**: `src/ColaFlow.Application/Auth/Commands/HandleSsoCallback/HandleSsoCallbackCommandHandler.cs`
|
|
|
|
```csharp
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ColaFlow.Application.Common.Interfaces;
|
|
using ColaFlow.Infrastructure.Persistence;
|
|
using ColaFlow.Infrastructure.Services;
|
|
using ColaFlow.Domain.Aggregates.UserAggregate;
|
|
using ColaFlow.Domain.Aggregates.UserAggregate.ValueObjects;
|
|
|
|
namespace ColaFlow.Application.Auth.Commands.HandleSsoCallback;
|
|
|
|
public sealed class HandleSsoCallbackCommandHandler
|
|
: IRequestHandler<HandleSsoCallbackCommand, HandleSsoCallbackResult>
|
|
{
|
|
private readonly ITenantContext _tenantContext;
|
|
private readonly ApplicationDbContext _context;
|
|
private readonly IJwtService _jwtService;
|
|
private readonly ILogger<HandleSsoCallbackCommandHandler> _logger;
|
|
|
|
public HandleSsoCallbackCommandHandler(
|
|
ITenantContext tenantContext,
|
|
ApplicationDbContext context,
|
|
IJwtService jwtService,
|
|
ILogger<HandleSsoCallbackCommandHandler> logger)
|
|
{
|
|
_tenantContext = tenantContext;
|
|
_context = context;
|
|
_jwtService = jwtService;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<HandleSsoCallbackResult> Handle(
|
|
HandleSsoCallbackCommand request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// 1. Validate state parameter (CSRF protection)
|
|
// (Handled by ASP.NET middleware)
|
|
|
|
// 2. Extract claims from SSO response
|
|
var claims = ExtractClaims(request.Principal);
|
|
|
|
// 3. Validate email domain (if restricted)
|
|
var tenant = await _context.Tenants
|
|
.IgnoreQueryFilters()
|
|
.FirstAsync(t => t.Id == _tenantContext.CurrentTenantId, cancellationToken);
|
|
|
|
if (tenant.SsoConfig is not null && !tenant.SsoConfig.IsEmailDomainAllowed(claims.Email))
|
|
{
|
|
throw new UnauthorizedAccessException($"Email domain '{claims.Email}' is not allowed for this tenant");
|
|
}
|
|
|
|
// 4. Find or create user
|
|
var user = await FindOrCreateUser(claims, cancellationToken);
|
|
|
|
// 5. Record login
|
|
user.RecordLogin();
|
|
await _context.SaveChangesAsync(cancellationToken);
|
|
|
|
// 6. Generate JWT token
|
|
var accessToken = _jwtService.GenerateAccessToken(user, tenant);
|
|
var refreshToken = _jwtService.GenerateRefreshToken(user);
|
|
|
|
_logger.LogInformation("SSO login successful for user {UserId} via {Provider}",
|
|
user.Id, claims.Provider);
|
|
|
|
return new HandleSsoCallbackResult(
|
|
accessToken,
|
|
refreshToken,
|
|
new UserDto(user.Id, user.Email, user.FullName, user.AvatarUrl));
|
|
}
|
|
|
|
private async Task<User> FindOrCreateUser(SsoClaims claims, CancellationToken cancellationToken)
|
|
{
|
|
// Try to find existing user by external ID
|
|
var user = await _context.Users
|
|
.FirstOrDefaultAsync(u =>
|
|
u.TenantId == _tenantContext.CurrentTenantId &&
|
|
u.AuthProvider == claims.Provider &&
|
|
u.ExternalUserId == claims.ExternalUserId,
|
|
cancellationToken);
|
|
|
|
if (user is not null)
|
|
{
|
|
// Update profile if changed
|
|
user.UpdateSsoProfile(
|
|
claims.ExternalUserId,
|
|
Email.Create(claims.Email),
|
|
FullName.Create(claims.FullName),
|
|
claims.AvatarUrl);
|
|
|
|
return user;
|
|
}
|
|
|
|
// Auto-provision new user (if enabled)
|
|
var tenant = await _context.Tenants
|
|
.IgnoreQueryFilters()
|
|
.FirstAsync(t => t.Id == _tenantContext.CurrentTenantId, cancellationToken);
|
|
|
|
if (tenant.SsoConfig?.AutoProvisionUsers != true)
|
|
{
|
|
throw new UnauthorizedAccessException("Auto-provisioning is disabled. Contact administrator.");
|
|
}
|
|
|
|
// Create new user
|
|
user = User.CreateFromSso(
|
|
_tenantContext.CurrentTenantId,
|
|
claims.Provider,
|
|
claims.ExternalUserId,
|
|
Email.Create(claims.Email),
|
|
FullName.Create(claims.FullName),
|
|
claims.AvatarUrl);
|
|
|
|
await _context.Users.AddAsync(user, cancellationToken);
|
|
|
|
_logger.LogInformation("Auto-provisioned new user {Email} from {Provider}",
|
|
claims.Email, claims.Provider);
|
|
|
|
return user;
|
|
}
|
|
|
|
private static SsoClaims ExtractClaims(ClaimsPrincipal principal)
|
|
{
|
|
var email = principal.FindFirst(ClaimTypes.Email)?.Value
|
|
?? principal.FindFirst("email")?.Value
|
|
?? throw new InvalidOperationException("Email claim not found");
|
|
|
|
var name = principal.FindFirst(ClaimTypes.Name)?.Value
|
|
?? principal.FindFirst("name")?.Value
|
|
?? email;
|
|
|
|
var externalUserId = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
|
?? principal.FindFirst("sub")?.Value
|
|
?? throw new InvalidOperationException("External user ID claim not found");
|
|
|
|
var avatarUrl = principal.FindFirst("picture")?.Value;
|
|
|
|
// Determine provider from claims issuer
|
|
var issuer = principal.FindFirst("iss")?.Value ?? string.Empty;
|
|
var provider = DetermineProvider(issuer);
|
|
|
|
return new SsoClaims(email, name, externalUserId, avatarUrl, provider);
|
|
}
|
|
|
|
private static AuthenticationProvider DetermineProvider(string issuer)
|
|
{
|
|
if (issuer.Contains("microsoft.com") || issuer.Contains("windows.net"))
|
|
return AuthenticationProvider.AzureAD;
|
|
|
|
if (issuer.Contains("google.com"))
|
|
return AuthenticationProvider.Google;
|
|
|
|
if (issuer.Contains("okta.com"))
|
|
return AuthenticationProvider.Okta;
|
|
|
|
return AuthenticationProvider.GenericSaml;
|
|
}
|
|
}
|
|
|
|
internal sealed record SsoClaims(
|
|
string Email,
|
|
string FullName,
|
|
string ExternalUserId,
|
|
string? AvatarUrl,
|
|
AuthenticationProvider Provider);
|
|
```
|
|
|
|
---
|
|
|
|
## User Auto-Provisioning
|
|
|
|
User auto-provisioning is handled in the `HandleSsoCallbackCommandHandler` above. Key features:
|
|
|
|
1. **First-time login**: Automatically creates user account if `AutoProvisionUsers = true`
|
|
2. **Claims mapping**: Maps IdP claims to User entity properties
|
|
3. **Profile sync**: Updates user profile on subsequent logins
|
|
4. **Domain restrictions**: Only allows specific email domains if configured
|
|
5. **Audit logging**: Records all SSO events
|
|
|
|
### Claims Mapping
|
|
|
|
| IdP Claim | ColaFlow User Property |
|
|
|-----------|------------------------|
|
|
| `sub` or `NameIdentifier` | `ExternalUserId` |
|
|
| `email` | `Email` |
|
|
| `name` | `FullName` |
|
|
| `picture` | `AvatarUrl` |
|
|
| `given_name` | (parsed into FullName) |
|
|
| `family_name` | (parsed into FullName) |
|
|
|
|
---
|
|
|
|
## SSO Configuration Management
|
|
|
|
### Configure SSO Command
|
|
|
|
**File**: `src/ColaFlow.Application/Tenants/Commands/ConfigureSso/ConfigureSsoCommand.cs`
|
|
|
|
```csharp
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate;
|
|
|
|
namespace ColaFlow.Application.Tenants.Commands.ConfigureSso;
|
|
|
|
public sealed record ConfigureSsoCommand(
|
|
SsoProvider Provider,
|
|
string? Authority = null,
|
|
string? ClientId = null,
|
|
string? ClientSecret = null,
|
|
string? MetadataUrl = null,
|
|
string? EntityId = null,
|
|
string? SignOnUrl = null,
|
|
string? Certificate = null,
|
|
bool AutoProvisionUsers = true,
|
|
string[]? AllowedDomains = null) : IRequest<Unit>;
|
|
```
|
|
|
|
**File**: `src/ColaFlow.Application/Tenants/Commands/ConfigureSso/ConfigureSsoCommandHandler.cs`
|
|
|
|
```csharp
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ColaFlow.Application.Common.Interfaces;
|
|
using ColaFlow.Infrastructure.Persistence;
|
|
using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
|
|
|
|
namespace ColaFlow.Application.Tenants.Commands.ConfigureSso;
|
|
|
|
public sealed class ConfigureSsoCommandHandler : IRequestHandler<ConfigureSsoCommand, Unit>
|
|
{
|
|
private readonly ITenantContext _tenantContext;
|
|
private readonly ApplicationDbContext _context;
|
|
private readonly ILogger<ConfigureSsoCommandHandler> _logger;
|
|
|
|
public ConfigureSsoCommandHandler(
|
|
ITenantContext tenantContext,
|
|
ApplicationDbContext context,
|
|
ILogger<ConfigureSsoCommandHandler> logger)
|
|
{
|
|
_tenantContext = tenantContext;
|
|
_context = context;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<Unit> Handle(ConfigureSsoCommand request, CancellationToken cancellationToken)
|
|
{
|
|
// 1. Load tenant
|
|
var tenant = await _context.Tenants
|
|
.IgnoreQueryFilters()
|
|
.FirstOrDefaultAsync(t => t.Id == _tenantContext.CurrentTenantId, cancellationToken);
|
|
|
|
if (tenant is null)
|
|
throw new InvalidOperationException("Tenant not found");
|
|
|
|
// 2. Create SSO configuration
|
|
SsoConfiguration ssoConfig;
|
|
|
|
if (request.Provider == SsoProvider.GenericSaml)
|
|
{
|
|
ssoConfig = SsoConfiguration.CreateSaml(
|
|
request.EntityId!,
|
|
request.SignOnUrl!,
|
|
request.Certificate!,
|
|
request.MetadataUrl,
|
|
request.AutoProvisionUsers,
|
|
request.AllowedDomains);
|
|
}
|
|
else
|
|
{
|
|
ssoConfig = SsoConfiguration.CreateOidc(
|
|
request.Provider,
|
|
request.Authority!,
|
|
request.ClientId!,
|
|
request.ClientSecret!, // TODO: Encrypt before storing
|
|
request.MetadataUrl,
|
|
request.AutoProvisionUsers,
|
|
request.AllowedDomains);
|
|
}
|
|
|
|
// 3. Update tenant
|
|
tenant.ConfigureSso(ssoConfig);
|
|
await _context.SaveChangesAsync(cancellationToken);
|
|
|
|
_logger.LogInformation("SSO configured for tenant {TenantId} with provider {Provider}",
|
|
_tenantContext.CurrentTenantId, request.Provider);
|
|
|
|
return Unit.Value;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Test SSO Connection Command
|
|
|
|
**File**: `src/ColaFlow.Application/Tenants/Commands/TestSsoConnection/TestSsoConnectionCommand.cs`
|
|
|
|
```csharp
|
|
namespace ColaFlow.Application.Tenants.Commands.TestSsoConnection;
|
|
|
|
public sealed record TestSsoConnectionCommand : IRequest<TestSsoConnectionResult>;
|
|
|
|
public sealed record TestSsoConnectionResult(
|
|
bool IsSuccessful,
|
|
string? ErrorMessage);
|
|
```
|
|
|
|
**File**: `src/ColaFlow.Application/Tenants/Commands/TestSsoConnection/TestSsoConnectionCommandHandler.cs`
|
|
|
|
```csharp
|
|
using System.Net.Http;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ColaFlow.Application.Common.Interfaces;
|
|
using ColaFlow.Infrastructure.Persistence;
|
|
|
|
namespace ColaFlow.Application.Tenants.Commands.TestSsoConnection;
|
|
|
|
public sealed class TestSsoConnectionCommandHandler
|
|
: IRequestHandler<TestSsoConnectionCommand, TestSsoConnectionResult>
|
|
{
|
|
private readonly ITenantContext _tenantContext;
|
|
private readonly ApplicationDbContext _context;
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
private readonly ILogger<TestSsoConnectionCommandHandler> _logger;
|
|
|
|
public TestSsoConnectionCommandHandler(
|
|
ITenantContext tenantContext,
|
|
ApplicationDbContext context,
|
|
IHttpClientFactory httpClientFactory,
|
|
ILogger<TestSsoConnectionCommandHandler> logger)
|
|
{
|
|
_tenantContext = tenantContext;
|
|
_context = context;
|
|
_httpClientFactory = httpClientFactory;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<TestSsoConnectionResult> Handle(
|
|
TestSsoConnectionCommand request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
// Load tenant SSO configuration
|
|
var tenant = await _context.Tenants
|
|
.IgnoreQueryFilters()
|
|
.FirstAsync(t => t.Id == _tenantContext.CurrentTenantId, cancellationToken);
|
|
|
|
if (tenant.SsoConfig is null)
|
|
return new TestSsoConnectionResult(false, "SSO is not configured");
|
|
|
|
// Test metadata endpoint
|
|
var metadataUrl = tenant.SsoConfig.MetadataUrl
|
|
?? $"{tenant.SsoConfig.Authority}/.well-known/openid-configuration";
|
|
|
|
var httpClient = _httpClientFactory.CreateClient();
|
|
var response = await httpClient.GetAsync(metadataUrl, cancellationToken);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
return new TestSsoConnectionResult(false,
|
|
$"Failed to fetch metadata: {response.StatusCode}");
|
|
}
|
|
|
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
|
|
if (string.IsNullOrEmpty(content))
|
|
{
|
|
return new TestSsoConnectionResult(false, "Metadata endpoint returned empty response");
|
|
}
|
|
|
|
_logger.LogInformation("SSO connection test successful for tenant {TenantId}",
|
|
_tenantContext.CurrentTenantId);
|
|
|
|
return new TestSsoConnectionResult(true, null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "SSO connection test failed for tenant {TenantId}",
|
|
_tenantContext.CurrentTenantId);
|
|
|
|
return new TestSsoConnectionResult(false, ex.Message);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Frontend Integration
|
|
|
|
### SSO Login Page
|
|
|
|
**File**: `src/frontend/app/auth/login/page.tsx`
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { Button, Divider, message } from 'antd';
|
|
import { MicrosoftOutlined, GoogleOutlined } from '@ant-design/icons';
|
|
|
|
export default function LoginPage() {
|
|
const router = useRouter();
|
|
const [loading, setLoading] = useState<string | null>(null);
|
|
|
|
const handleSsoLogin = async (provider: 'AzureAD' | 'Google' | 'Okta') => {
|
|
try {
|
|
setLoading(provider);
|
|
|
|
// Initiate SSO flow
|
|
const response = await fetch('/api/auth/sso/initiate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ provider }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to initiate SSO');
|
|
}
|
|
|
|
const { redirectUrl, state } = await response.json();
|
|
|
|
// Store state in session storage (CSRF protection)
|
|
sessionStorage.setItem('sso_state', state);
|
|
|
|
// Redirect to IdP
|
|
window.location.href = redirectUrl;
|
|
} catch (error) {
|
|
message.error('Failed to initiate SSO login');
|
|
console.error(error);
|
|
setLoading(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
|
<div className="max-w-md w-full bg-white p-8 rounded-lg shadow-lg">
|
|
<h1 className="text-2xl font-bold text-center mb-6">
|
|
Sign in to ColaFlow
|
|
</h1>
|
|
|
|
{/* Local authentication form */}
|
|
<form>
|
|
{/* ... email + password fields ... */}
|
|
<Button type="primary" htmlType="submit" block>
|
|
Sign In
|
|
</Button>
|
|
</form>
|
|
|
|
<Divider>Or continue with</Divider>
|
|
|
|
{/* SSO buttons */}
|
|
<div className="space-y-3">
|
|
<Button
|
|
icon={<MicrosoftOutlined />}
|
|
onClick={() => handleSsoLogin('AzureAD')}
|
|
loading={loading === 'AzureAD'}
|
|
block
|
|
size="large"
|
|
>
|
|
Sign in with Microsoft
|
|
</Button>
|
|
|
|
<Button
|
|
icon={<GoogleOutlined />}
|
|
onClick={() => handleSsoLogin('Google')}
|
|
loading={loading === 'Google'}
|
|
block
|
|
size="large"
|
|
>
|
|
Sign in with Google
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### SSO Callback Handler
|
|
|
|
**File**: `src/frontend/app/auth/sso/callback/page.tsx`
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import { Spin, Result } from 'antd';
|
|
|
|
export default function SsoCallbackPage() {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
const handleCallback = async () => {
|
|
try {
|
|
// Validate state parameter (CSRF protection)
|
|
const state = searchParams.get('state');
|
|
const storedState = sessionStorage.getItem('sso_state');
|
|
|
|
if (!state || state !== storedState) {
|
|
throw new Error('Invalid state parameter');
|
|
}
|
|
|
|
sessionStorage.removeItem('sso_state');
|
|
|
|
// Backend will handle the SSO response
|
|
// JWT token is already set in HTTP-only cookie by middleware
|
|
|
|
// Redirect to dashboard
|
|
router.push('/dashboard');
|
|
} catch (err: any) {
|
|
setError(err.message || 'SSO authentication failed');
|
|
}
|
|
};
|
|
|
|
handleCallback();
|
|
}, [searchParams, router]);
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<Result
|
|
status="error"
|
|
title="Authentication Failed"
|
|
subTitle={error}
|
|
extra={
|
|
<Button type="primary" onClick={() => router.push('/auth/login')}>
|
|
Back to Login
|
|
</Button>
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<Spin size="large" tip="Completing sign-in..." />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### SSO Settings Page (Admin)
|
|
|
|
**File**: `src/frontend/app/settings/sso/page.tsx`
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { Form, Input, Select, Switch, Button, message, Alert } from 'antd';
|
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
|
|
const { Option } = Select;
|
|
|
|
export default function SsoSettingsPage() {
|
|
const [form] = Form.useForm();
|
|
const [provider, setProvider] = useState<string>('AzureAD');
|
|
|
|
// Load current SSO configuration
|
|
const { data: ssoConfig } = useQuery({
|
|
queryKey: ['sso-config'],
|
|
queryFn: () => fetch('/api/tenants/sso').then(res => res.json()),
|
|
});
|
|
|
|
// Test SSO connection
|
|
const testConnection = useMutation({
|
|
mutationFn: () =>
|
|
fetch('/api/tenants/sso/test', { method: 'POST' }).then(res => res.json()),
|
|
onSuccess: (data) => {
|
|
if (data.isSuccessful) {
|
|
message.success('SSO connection successful!');
|
|
} else {
|
|
message.error(`Connection failed: ${data.errorMessage}`);
|
|
}
|
|
},
|
|
});
|
|
|
|
// Save SSO configuration
|
|
const saveSsoConfig = useMutation({
|
|
mutationFn: (values: any) =>
|
|
fetch('/api/tenants/sso', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(values),
|
|
}),
|
|
onSuccess: () => {
|
|
message.success('SSO configuration saved successfully');
|
|
},
|
|
});
|
|
|
|
return (
|
|
<div className="max-w-3xl mx-auto p-6">
|
|
<h1 className="text-2xl font-bold mb-6">Single Sign-On (SSO) Configuration</h1>
|
|
|
|
<Alert
|
|
message="Pro and Enterprise Plans Only"
|
|
description="SSO integration is available for Pro and Enterprise plans."
|
|
type="info"
|
|
showIcon
|
|
className="mb-6"
|
|
/>
|
|
|
|
<Form
|
|
form={form}
|
|
layout="vertical"
|
|
initialValues={ssoConfig}
|
|
onFinish={(values) => saveSsoConfig.mutate(values)}
|
|
>
|
|
<Form.Item
|
|
label="SSO Provider"
|
|
name="provider"
|
|
rules={[{ required: true }]}
|
|
>
|
|
<Select onChange={setProvider}>
|
|
<Option value="AzureAD">Azure AD / Microsoft Entra</Option>
|
|
<Option value="Google">Google Workspace</Option>
|
|
<Option value="Okta">Okta</Option>
|
|
<Option value="GenericSaml">Generic SAML 2.0</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
|
|
{provider !== 'GenericSaml' ? (
|
|
<>
|
|
<Form.Item
|
|
label="Authority / Issuer URL"
|
|
name="authority"
|
|
rules={[{ required: true, type: 'url' }]}
|
|
>
|
|
<Input placeholder="https://login.microsoftonline.com/tenant-id" />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label="Client ID"
|
|
name="clientId"
|
|
rules={[{ required: true }]}
|
|
>
|
|
<Input placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label="Client Secret"
|
|
name="clientSecret"
|
|
rules={[{ required: true }]}
|
|
>
|
|
<Input.Password placeholder="Enter client secret" />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label="Metadata URL (Optional)"
|
|
name="metadataUrl"
|
|
>
|
|
<Input placeholder="https://login.microsoftonline.com/.well-known/openid-configuration" />
|
|
</Form.Item>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Form.Item
|
|
label="Entity ID"
|
|
name="entityId"
|
|
rules={[{ required: true }]}
|
|
>
|
|
<Input placeholder="https://idp.example.com/saml" />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label="Sign-On URL"
|
|
name="signOnUrl"
|
|
rules={[{ required: true, type: 'url' }]}
|
|
>
|
|
<Input placeholder="https://idp.example.com/sso" />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label="X.509 Certificate"
|
|
name="certificate"
|
|
rules={[{ required: true }]}
|
|
>
|
|
<Input.TextArea
|
|
rows={6}
|
|
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
|
/>
|
|
</Form.Item>
|
|
</>
|
|
)}
|
|
|
|
<Form.Item
|
|
label="Auto-Provision Users"
|
|
name="autoProvisionUsers"
|
|
valuePropName="checked"
|
|
>
|
|
<Switch />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label="Allowed Email Domains (Optional)"
|
|
name="allowedDomains"
|
|
help="Comma-separated list, e.g., acme.com,acme.org"
|
|
>
|
|
<Input placeholder="acme.com, acme.org" />
|
|
</Form.Item>
|
|
|
|
<div className="flex gap-3">
|
|
<Button
|
|
type="primary"
|
|
htmlType="submit"
|
|
loading={saveSsoConfig.isPending}
|
|
>
|
|
Save Configuration
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={() => testConnection.mutate()}
|
|
loading={testConnection.isPending}
|
|
>
|
|
Test Connection
|
|
</Button>
|
|
</div>
|
|
</Form>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Security Considerations
|
|
|
|
### 1. SAML Response Signature Verification
|
|
|
|
SAML responses MUST be signed by the IdP to prevent tampering:
|
|
|
|
```csharp
|
|
// Configured in SAML options
|
|
options.SPOptions.WantAssertionsSigned = true;
|
|
idp.WantAuthnRequestsSigned = true;
|
|
|
|
// Certificate validation
|
|
idp.SigningKeys.AddConfiguredKey(idpCertificate);
|
|
```
|
|
|
|
### 2. OAuth State Parameter (CSRF Protection)
|
|
|
|
State parameter prevents CSRF attacks:
|
|
|
|
```csharp
|
|
// Generate secure random state
|
|
var state = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
|
|
|
// Store in session
|
|
httpContext.Session.SetString("SsoState", state);
|
|
|
|
// Validate on callback
|
|
var receivedState = httpContext.Request.Query["state"];
|
|
var storedState = httpContext.Session.GetString("SsoState");
|
|
|
|
if (receivedState != storedState)
|
|
throw new SecurityException("Invalid state parameter");
|
|
```
|
|
|
|
### 3. ID Token Validation
|
|
|
|
OIDC ID tokens must be validated:
|
|
|
|
```csharp
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidIssuer = ssoConfig.Authority,
|
|
|
|
ValidateAudience = true,
|
|
ValidAudience = ssoConfig.ClientId,
|
|
|
|
ValidateLifetime = true,
|
|
ClockSkew = TimeSpan.FromMinutes(5),
|
|
|
|
ValidateIssuerSigningKey = true,
|
|
// Signing keys fetched from metadata endpoint
|
|
};
|
|
```
|
|
|
|
### 4. Replay Attack Prevention
|
|
|
|
```csharp
|
|
// OIDC: nonce parameter
|
|
options.ProtocolValidator.RequireNonce = true;
|
|
|
|
// SAML: InResponseTo validation
|
|
options.SPOptions.ReturnUrl = new Uri("https://colaflow.com/callback");
|
|
```
|
|
|
|
### 5. Client Secret Encryption
|
|
|
|
Encrypt client secrets before storing in database:
|
|
|
|
```csharp
|
|
public sealed class SecretEncryptionService
|
|
{
|
|
private readonly IDataProtectionProvider _dataProtection;
|
|
|
|
public string Encrypt(string plainText)
|
|
{
|
|
var protector = _dataProtection.CreateProtector("SsoClientSecrets");
|
|
return protector.Protect(plainText);
|
|
}
|
|
|
|
public string Decrypt(string cipherText)
|
|
{
|
|
var protector = _dataProtection.CreateProtector("SsoClientSecrets");
|
|
return protector.Unprotect(cipherText);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
### Integration Test - OIDC Login Flow
|
|
|
|
**File**: `tests/ColaFlow.API.Tests/Auth/SsoLoginFlowTests.cs`
|
|
|
|
```csharp
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Xunit;
|
|
|
|
namespace ColaFlow.API.Tests.Auth;
|
|
|
|
public sealed class SsoLoginFlowTests : IClassFixture<WebApplicationFactory<Program>>
|
|
{
|
|
private readonly HttpClient _client;
|
|
|
|
public SsoLoginFlowTests(WebApplicationFactory<Program> factory)
|
|
{
|
|
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
|
{
|
|
AllowAutoRedirect = false
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InitiateSso_ShouldRedirectToIdP()
|
|
{
|
|
// Arrange
|
|
var request = new { provider = "AzureAD" };
|
|
|
|
// Act
|
|
var response = await _client.PostAsJsonAsync("/api/auth/sso/initiate", request);
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
|
Assert.NotNull(response.Headers.Location);
|
|
Assert.Contains("login.microsoftonline.com", response.Headers.Location.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SsoCallback_WithInvalidState_ShouldReturnUnauthorized()
|
|
{
|
|
// Arrange
|
|
var invalidState = "invalid-state";
|
|
|
|
// Act
|
|
var response = await _client.GetAsync($"/api/auth/sso/callback?state={invalidState}");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Unit Test - Claims Mapping
|
|
|
|
**File**: `tests/ColaFlow.Application.Tests/Auth/ClaimsMappingTests.cs`
|
|
|
|
```csharp
|
|
using System.Security.Claims;
|
|
using ColaFlow.Application.Auth.Commands.HandleSsoCallback;
|
|
using Xunit;
|
|
|
|
namespace ColaFlow.Application.Tests.Auth;
|
|
|
|
public sealed class ClaimsMappingTests
|
|
{
|
|
[Fact]
|
|
public void ExtractClaims_ShouldMapAzureAdClaims()
|
|
{
|
|
// Arrange
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim("sub", "azure-user-id-123"),
|
|
new Claim("email", "user@acme.com"),
|
|
new Claim("name", "John Doe"),
|
|
new Claim("iss", "https://login.microsoftonline.com/tenant-id")
|
|
}));
|
|
|
|
// Act
|
|
var claims = ExtractClaims(principal);
|
|
|
|
// Assert
|
|
Assert.Equal("user@acme.com", claims.Email);
|
|
Assert.Equal("John Doe", claims.FullName);
|
|
Assert.Equal("azure-user-id-123", claims.ExternalUserId);
|
|
Assert.Equal(AuthenticationProvider.AzureAD, claims.Provider);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
This SSO integration architecture provides:
|
|
|
|
✅ **Industry-Standard Protocols**: OIDC and SAML 2.0 support
|
|
✅ **Multiple IdPs**: Azure AD, Google, Okta, Generic SAML
|
|
✅ **Auto-Provisioning**: Automatically create users on first SSO login
|
|
✅ **Domain Restrictions**: Limit SSO to specific email domains
|
|
✅ **Security First**: State parameters, signature validation, token validation
|
|
✅ **Flexible Configuration**: Per-tenant SSO settings
|
|
✅ **Seamless UX**: Beautiful login UI with SSO buttons
|
|
✅ **Complete Testing**: Integration and unit tests for all flows
|
|
|
|
**Next Steps**:
|
|
1. Implement MCP Authentication (see `mcp-authentication-architecture.md`)
|
|
2. Update JWT generation to include SSO claims
|
|
3. Execute database migration
|