Skip to content

Commit 1d5f5c4

Browse files
author
Khumeren
committed
todo: fix google and local login problems
1 parent 98ce62d commit 1d5f5c4

File tree

14 files changed

+540
-42
lines changed

14 files changed

+540
-42
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using FastEndpoints;
2+
using MediatR;
3+
using Rgt.Space.API.ProblemDetails;
4+
using LoginCommand = Rgt.Space.Infrastructure.Commands.Auth.Login;
5+
6+
namespace Rgt.Space.API.Endpoints.Auth.Login;
7+
8+
public class LoginRequest
9+
{
10+
public string Email { get; set; } = default!;
11+
public string Password { get; set; } = default!;
12+
}
13+
14+
public class LoginResponse
15+
{
16+
public string AccessToken { get; set; } = default!;
17+
public string RefreshToken { get; set; } = default!;
18+
public DateTime ExpiresAt { get; set; }
19+
public string UserId { get; set; } = default!;
20+
public string DisplayName { get; set; } = default!;
21+
public string Email { get; set; } = default!;
22+
}
23+
24+
public sealed class Endpoint(IMediator mediator) : Endpoint<LoginRequest, LoginResponse>
25+
{
26+
public override void Configure()
27+
{
28+
Post("/api/v1/auth/login");
29+
AllowAnonymous(); // Login doesn't require authentication
30+
Summary(s =>
31+
{
32+
s.Summary = "Local login with email and password";
33+
s.Description = "Authenticates a user with email/password and returns JWT tokens";
34+
s.Response<LoginResponse>(200, "Login successful");
35+
s.Response(400, "Validation failure");
36+
s.Response(401, "Invalid credentials");
37+
s.Response(403, "Account disabled or local login not enabled");
38+
});
39+
Tags("Authentication");
40+
}
41+
42+
public override async Task HandleAsync(LoginRequest req, CancellationToken ct)
43+
{
44+
var command = new LoginCommand.Command(req.Email, req.Password);
45+
var result = await mediator.Send(command, ct);
46+
47+
if (result.IsFailed)
48+
{
49+
var problemDetails = result.ToProblemDetails(HttpContext);
50+
await HttpContext.Response.SendAsync(problemDetails, problemDetails.Status ?? 500, cancellation: ct);
51+
return;
52+
}
53+
54+
var response = new LoginResponse
55+
{
56+
AccessToken = result.Value.AccessToken,
57+
RefreshToken = result.Value.RefreshToken,
58+
ExpiresAt = result.Value.ExpiresAt,
59+
UserId = result.Value.UserId.ToString(),
60+
DisplayName = result.Value.DisplayName,
61+
Email = result.Value.Email
62+
};
63+
64+
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
65+
}
66+
}

Rgt.Space.API/ProblemDetails/ProblemDetailsFactory.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ namespace Rgt.Space.API.ProblemDetails
77
/// <summary>
88
/// Factory for creating RFC 7807 ProblemDetails responses enriched with:
99
/// - Correlation ID (for request tracing)
10-
/// - Tenant ID (for multi-tenant context)
1110
/// - Error code (for client-side handling)
1211
/// - Trace ID (for distributed tracing)
1312
/// </summary>

Rgt.Space.API/Program.cs

Lines changed: 110 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -83,139 +83,209 @@
8383
});
8484
});
8585

86-
// Add Authentication with JWT Bearer (RSA-SHA256 via OIDC Discovery)
87-
builder.Services.AddAuthentication(Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerDefaults.AuthenticationScheme)
88-
.AddJwtBearer(options =>
86+
// Add Authentication with DUAL JWT Bearer support:
87+
// 1. SSO Broker tokens (RSA-SHA256 via OIDC Discovery)
88+
// 2. Local Login tokens (HMAC-SHA256 symmetric)
89+
builder.Services.AddAuthentication(options =>
90+
{
91+
options.DefaultAuthenticateScheme = "MultiScheme";
92+
options.DefaultChallengeScheme = "MultiScheme";
93+
})
94+
.AddJwtBearer("SsoBearer", options =>
8995
{
9096
var authConfig = builder.Configuration.GetSection("Auth");
9197

92-
// Disable claim type mapping to keep claims as-is (e.g. "sub" -> "sub")
98+
// Disable claim type mapping to keep claims as-is
9399
options.MapInboundClaims = false;
94100

95101
// Point to SSO Broker for OIDC Discovery
96102
options.Authority = authConfig["Authority"];
97103
options.Audience = authConfig["Audience"];
98104

99-
// Enable HTTPS metadata discovery (set to false ONLY for localhost dev with self-signed certs)
105+
// Enable HTTPS metadata discovery (set to false ONLY for localhost dev)
100106
options.RequireHttpsMetadata = false; // TODO: Set to true in production
101107

102108
// Token validation parameters
103109
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
104110
{
105111
ValidateIssuer = true,
106112
ValidIssuer = authConfig["Authority"],
107-
108113
ValidateAudience = true,
109114
ValidAudience = authConfig["Audience"],
110-
111115
ValidateLifetime = true,
112116
ClockSkew = TimeSpan.FromMinutes(5),
113117
};
114118

115-
// DEV ONLY: DIAGNOSTIC - Hardcode the key to bypass Discovery issues
119+
// DEV ONLY: Hardcode RSA key to bypass Discovery issues
116120
if (builder.Environment.IsDevelopment())
117121
{
118122
try
119123
{
120124
var rsa = System.Security.Cryptography.RSA.Create();
121125
rsa.ImportParameters(new System.Security.Cryptography.RSAParameters
122126
{
123-
// Values from https://localhost:7012/.well-known/jwks.json
124127
Modulus = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes("rNH-ckvzkKRcqAKmb8CDdABZ-4_fUgI-vjSRoDfz-kCDtFdxTD69XvqUGP4NRyPXiSwI3ODh1_iBv-eg1RCBB8iA8eNLHuD5VbeMq4J5_ktCUjAUBQ783cs9R_7RKyLRrlW-Cq0EiZ-Z0I5vyWE9yzCN7Mf1MU2cn4GnAxMsJFlMwNEstbupqZWIgZXqLxrHcXcUpS-zpPkJULI4tDsUTjXMih8hU2ikrb_EltNYi0tcIBV6TfoBEc3OGiz8ao4mZ8UiKLBMwUi00qvQRtGl3xm0idh3sF2sGunIkTlRFtsBzjNpTqcAotyRXTNuQOTExX_dRL8C74eHUwd2J9quQQ"),
125128
Exponent = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes("AQAB")
126129
});
127130

128131
var key = new Microsoft.IdentityModel.Tokens.RsaSecurityKey(rsa) { KeyId = "rsa-2025-11-27" };
129132

130-
// FORCE THE KEY and DISABLE DISCOVERY
131133
options.TokenValidationParameters.IssuerSigningKey = key;
132134
options.Configuration = new Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfiguration
133135
{
134136
Issuer = authConfig["Authority"],
135137
};
136138
options.Configuration.SigningKeys.Add(key);
137139

138-
Log.Warning("DEV MODE: Using Hardcoded RSA Key for Token Validation to bypass OIDC Discovery.");
140+
Log.Warning("DEV MODE: SsoBearer using Hardcoded RSA Key.");
139141
}
140142
catch (Exception ex)
141143
{
142-
Log.Error(ex, "Failed to configure hardcoded key.");
144+
Log.Error(ex, "Failed to configure SSO hardcoded key.");
143145
}
144146
}
145-
else
146-
{
147-
// Production or other envs: Use standard discovery
148-
// (The previous explicit config manager code was removed for this test,
149-
// but in prod we would just rely on default behavior)
150-
}
151147

152-
// JIT User Provisioning: Sync user from SSO on every token validation
148+
// JIT User Provisioning for SSO tokens
153149
options.Events = new Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerEvents
154150
{
155151
OnTokenValidated = async context =>
156152
{
157-
// DEBUG: Log all claims to see what we are actually getting
158153
var claims = context.Principal?.Claims.Select(c => $"{c.Type}: {c.Value}").ToList();
159154
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
160155
logger.LogInformation("SSO Token Validated. Claims: {Claims}", string.Join(", ", claims ?? new List<string>()));
161156

162-
// Extract claims from the validated token
163-
// Try standard short names first, then fallback to SOAP/XML names if needed
164157
var subject = context.Principal?.FindFirst("sub")?.Value
165158
?? context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
166-
167159
var email = context.Principal?.FindFirst("email")?.Value
168160
?? context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value;
169-
170161
var name = context.Principal?.FindFirst("name")?.Value
171-
?? context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value
172-
?? email;
173-
162+
?? context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value ?? email;
174163
var issuer = context.Principal?.FindFirst("iss")?.Value;
175164

176165
if (string.IsNullOrEmpty(subject) || string.IsNullOrEmpty(email))
177166
{
178-
logger.LogError("Token is missing required claims. Sub: {Sub}, Email: {Email}", subject, email);
167+
logger.LogError("SSO Token missing required claims. Sub: {Sub}, Email: {Email}", subject, email);
179168
context.Fail("Token is missing required claims (sub, email)");
180169
return;
181170
}
182171

183-
// Determine provider from issuer (e.g., "https://localhost:7012" -> "sso_broker")
184172
var provider = issuer?.Contains("localhost") == true ? "sso_broker" : "azuread";
185173

186174
try
187175
{
188-
// Resolve Identity Sync Service and sync the user
189176
var syncService = context.HttpContext.RequestServices
190177
.GetRequiredService<Rgt.Space.Core.Abstractions.Identity.IIdentitySyncService>();
191-
192-
// Sync user and get Local ID
193178
var localUserId = await syncService.SyncOrGetUserAsync(provider, subject, email, name!, context.HttpContext.RequestAborted);
194179

195-
// Attach Local ID to Principal
196180
var claimsIdentity = context.Principal?.Identity as System.Security.Claims.ClaimsIdentity;
197181
claimsIdentity?.AddClaim(new System.Security.Claims.Claim("x-local-user-id", localUserId.ToString()));
198182
}
199183
catch (Exception ex)
200184
{
201-
// Log the error but don't fail authentication (user might already exist)
202185
logger.LogError(ex, "Failed to sync user from SSO. Subject: {Subject}, Email: {Email}", subject, email);
203-
204-
// Don't fail auth - user might already be provisioned
205-
// context.Fail() would reject valid tokens which is worse than missing a sync
206186
}
207187
},
188+
OnAuthenticationFailed = context =>
189+
{
190+
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
191+
logger.LogDebug("SSO Authentication failed: {Exception}", context.Exception.Message);
192+
return Task.CompletedTask;
193+
}
194+
};
195+
})
196+
.AddJwtBearer("LocalBearer", options =>
197+
{
198+
var localAuthConfig = builder.Configuration.GetSection("LocalAuth");
199+
200+
// Disable claim type mapping
201+
options.MapInboundClaims = false;
208202

203+
// Local token validation parameters (HMAC-SHA256)
204+
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
205+
{
206+
ValidateIssuer = true,
207+
ValidIssuer = localAuthConfig["Issuer"] ?? "rgt-space-portal",
208+
209+
ValidateAudience = true,
210+
ValidAudience = localAuthConfig["Audience"] ?? "rgt-space-portal-api",
211+
212+
ValidateLifetime = true,
213+
ClockSkew = TimeSpan.FromMinutes(5),
214+
215+
ValidateIssuerSigningKey = true,
216+
IssuerSigningKey = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(
217+
System.Text.Encoding.UTF8.GetBytes(
218+
localAuthConfig["SigningKey"] ?? "YourSuperSecretLocalSigningKey_ChangeThisInProduction_MustBe32CharactersLong!")),
219+
};
220+
221+
options.Events = new Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerEvents
222+
{
223+
OnTokenValidated = context =>
224+
{
225+
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
226+
var claims = context.Principal?.Claims.Select(c => $"{c.Type}: {c.Value}").ToList();
227+
logger.LogInformation("Local Token Validated. Claims: {Claims}", string.Join(", ", claims ?? new List<string>()));
228+
229+
// For local tokens, the "sub" claim IS the local user ID
230+
var userId = context.Principal?.FindFirst("sub")?.Value;
231+
if (!string.IsNullOrEmpty(userId))
232+
{
233+
var claimsIdentity = context.Principal?.Identity as System.Security.Claims.ClaimsIdentity;
234+
claimsIdentity?.AddClaim(new System.Security.Claims.Claim("x-local-user-id", userId));
235+
}
236+
237+
return Task.CompletedTask;
238+
},
209239
OnAuthenticationFailed = context =>
210240
{
211-
var logger = context.HttpContext.RequestServices
212-
.GetRequiredService<ILogger<Program>>();
213-
logger.LogWarning("JWT Authentication failed: {Exception}", context.Exception.Message);
241+
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
242+
logger.LogDebug("Local Authentication failed: {Exception}", context.Exception.Message);
214243
return Task.CompletedTask;
215244
}
216245
};
246+
})
247+
.AddPolicyScheme("MultiScheme", "SSO or Local", options =>
248+
{
249+
// Smart scheme selection: Try SSO first, then Local
250+
options.ForwardDefaultSelector = context =>
251+
{
252+
// Check if there's a Bearer token
253+
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
254+
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
255+
{
256+
return "LocalBearer"; // Default to local if no token
257+
}
258+
259+
// Extract the token
260+
var token = authHeader.Substring("Bearer ".Length).Trim();
261+
262+
try
263+
{
264+
// Peek at the token header to determine type
265+
var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
266+
if (handler.CanReadToken(token))
267+
{
268+
var jwt = handler.ReadJwtToken(token);
269+
270+
// If token has "kid" header, it's from SSO (RSA)
271+
// If issuer is "rgt-space-portal", it's local
272+
var issuer = jwt.Issuer;
273+
if (issuer == "rgt-space-portal")
274+
{
275+
return "LocalBearer";
276+
}
277+
}
278+
}
279+
catch
280+
{
281+
// If we can't parse it, try SSO first
282+
}
283+
284+
return "SsoBearer";
285+
};
217286
});
218287

288+
219289
builder.Services.AddAuthorization();
220290

221291
// Add API versioning

Rgt.Space.API/appsettings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010
"Authority": "https://localhost:7012",
1111
"Audience": "rgt-space-portal-api"
1212
},
13+
"LocalAuth": {
14+
"Issuer": "rgt-space-portal",
15+
"Audience": "rgt-space-portal-api",
16+
"SigningKey": "YourSuperSecretLocalSigningKey_ChangeThisInProduction_MustBe32CharactersLong!",
17+
"AccessTokenExpiryMinutes": 60,
18+
"RefreshTokenExpiryDays": 7
19+
},
1320
"CacheSettings": {
1421
"TenantConnectionStringTTL": 600,
1522
"InstanceName": "MicroservicesBase:"

Rgt.Space.Core/Abstractions/Identity/IUserReadDac.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,11 @@ public interface IUserReadDac
1111
Task<IReadOnlyList<UserReadModel>> GetAllAsync(CancellationToken ct);
1212
Task<IReadOnlyList<UserReadModel>> SearchAsync(string searchTerm, CancellationToken ct);
1313
Task<IReadOnlyList<UserPermissionReadModel>> GetPermissionsAsync(Guid userId, CancellationToken ct);
14+
15+
/// <summary>
16+
/// Retrieves user credentials for login verification.
17+
/// Only returns active, non-deleted users with local login enabled.
18+
/// </summary>
19+
Task<UserCredentialsReadModel?> GetCredentialsByEmailAsync(string email, CancellationToken ct);
1420
}
21+

Rgt.Space.Core/Domain/Contracts/Dashboard/DashboardStatsResponse.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@ public AssignmentDistribution(string positionCode, int count)
3131

3232
public sealed record VacantPosition
3333
{
34+
public Guid ProjectId { get; init; }
3435
public string ProjectName { get; init; } = string.Empty;
3536
public string MissingPosition { get; init; } = string.Empty;
3637

3738
public VacantPosition() { }
3839

39-
public VacantPosition(string projectName, string missingPosition)
40+
public VacantPosition(Guid projectId, string projectName, string missingPosition)
4041
{
42+
ProjectId = projectId;
4143
ProjectName = projectName;
4244
MissingPosition = missingPosition;
4345
}

Rgt.Space.Core/Errors/ErrorCatalog.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ public static class ErrorCatalog
5757
public const string ROLE_HAS_USERS = "ROLE_HAS_USERS";
5858
public const string ROLE_IS_SYSTEM = "ROLE_IS_SYSTEM";
5959

60+
// ===== Authentication Errors =====
61+
public const string INVALID_CREDENTIALS = "INVALID_CREDENTIALS";
62+
public const string ACCOUNT_DISABLED = "ACCOUNT_DISABLED";
63+
public const string LOCAL_LOGIN_DISABLED = "LOCAL_LOGIN_DISABLED";
64+
public const string PASSWORD_EXPIRED = "PASSWORD_EXPIRED";
65+
6066
/// <summary>
6167
/// Determines if an error code represents a validation error.
6268
/// Validation errors return 400 Bad Request.
@@ -155,6 +161,12 @@ public static int GetStatusCode(string errorCode)
155161
ROLE_CODE_EXISTS => 409,
156162
ROLE_HAS_USERS => 409,
157163
ROLE_IS_SYSTEM => 403,
164+
165+
// Authentication
166+
INVALID_CREDENTIALS => 401,
167+
ACCOUNT_DISABLED => 403,
168+
LOCAL_LOGIN_DISABLED => 403,
169+
PASSWORD_EXPIRED => 403,
158170

159171
// 500 Internal Server Error (default)
160172
_ => 500

0 commit comments

Comments
 (0)