Add Steam OpenID authentication
Deploy Promiscuity Auth API / deploy (push) Failing after 20s
Deploy Promiscuity Character API / deploy (push) Failing after 19s
Deploy Promiscuity Crafting API / deploy (push) Failing after 20s
Deploy Promiscuity Inventory API / deploy (push) Failing after 19s
Deploy Promiscuity Locations API / deploy (push) Failing after 19s
Deploy Promiscuity Mail API / deploy (push) Failing after 20s
Deploy Promiscuity World API / deploy (push) Failing after 20s
k8s smoke test / test (push) Failing after 20s

This commit is contained in:
2026-07-20 00:30:06 -05:00
parent 18c3d6fc5d
commit cde0e28e44
16 changed files with 564 additions and 29 deletions
@@ -0,0 +1,33 @@
using AuthApi.Models;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace AuthApi.Services;
public class JwtTokenService
{
private readonly IConfiguration _cfg;
public JwtTokenService(IConfiguration cfg) => _cfg = cfg;
public (string Token, string Jti, DateTime ExpiresUtc) Generate(User user)
{
var key = Encoding.UTF8.GetBytes(_cfg["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key missing"));
var issuer = _cfg["Jwt:Issuer"] ?? "GameAuthApi";
var audience = _cfg["Jwt:Audience"] ?? issuer;
var credentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256);
var jti = Guid.NewGuid().ToString("N");
var claims = new[]
{
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(ClaimTypes.Role, user.Role),
new Claim(JwtRegisteredClaimNames.Jti, jti)
};
var expiresUtc = DateTime.UtcNow.AddMinutes(15);
var token = new JwtSecurityToken(issuer, audience, claims, expires: expiresUtc, signingCredentials: credentials);
return (new JwtSecurityTokenHandler().WriteToken(token), jti, expiresUtc);
}
}
@@ -0,0 +1,49 @@
using AuthApi.Models;
using MongoDB.Driver;
namespace AuthApi.Services;
public class SteamAuthTransactionService
{
private readonly IMongoCollection<SteamAuthTransaction> _transactions;
public SteamAuthTransactionService(IConfiguration cfg)
{
var connectionString = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
var databaseName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
var database = new MongoClient(connectionString).GetDatabase(databaseName);
_transactions = database.GetCollection<SteamAuthTransaction>("SteamAuthTransactions");
var ttl = Builders<SteamAuthTransaction>.IndexKeys.Ascending(x => x.ExpiresUtc);
_transactions.Indexes.CreateOne(new CreateIndexModel<SteamAuthTransaction>(ttl,
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero }));
}
public Task CreateAsync(SteamAuthTransaction transaction) => _transactions.InsertOneAsync(transaction);
public async Task<SteamAuthTransaction?> GetAsync(string id) =>
await _transactions.Find(x => x.Id == id).FirstOrDefaultAsync();
public async Task<bool> MarkVerifiedAsync(string id, string steamId, string status, string? userId)
{
var filter = Builders<SteamAuthTransaction>.Filter.Eq(x => x.Id, id) &
Builders<SteamAuthTransaction>.Filter.Eq(x => x.Status, "pending") &
Builders<SteamAuthTransaction>.Filter.Gt(x => x.ExpiresUtc, DateTime.UtcNow);
var update = Builders<SteamAuthTransaction>.Update
.Set(x => x.SteamId, steamId)
.Set(x => x.Status, status)
.Set(x => x.UserId, userId);
return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public async Task<bool> MarkCompleteAsync(string id, string steamId, string userId)
{
var filter = Builders<SteamAuthTransaction>.Filter.Eq(x => x.Id, id) &
Builders<SteamAuthTransaction>.Filter.Eq(x => x.Status, "requires_account") &
Builders<SteamAuthTransaction>.Filter.Eq(x => x.SteamId, steamId) &
Builders<SteamAuthTransaction>.Filter.Gt(x => x.ExpiresUtc, DateTime.UtcNow);
var update = Builders<SteamAuthTransaction>.Update
.Set(x => x.Status, "complete")
.Set(x => x.UserId, userId);
return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
}
@@ -0,0 +1,66 @@
using System.Text.RegularExpressions;
namespace AuthApi.Services;
public class SteamOpenIdService
{
public const string Endpoint = "https://steamcommunity.com/openid/login";
private static readonly Regex ClaimedIdPattern = new(
"^https?://steamcommunity\\.com/openid/id/(?<id>[0-9]{17})$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly IHttpClientFactory _httpClients;
public SteamOpenIdService(IHttpClientFactory httpClients) => _httpClients = httpClients;
public static string BuildAuthenticationUrl(string realm, string returnUrl)
{
var parameters = new Dictionary<string, string>
{
["openid.ns"] = "http://specs.openid.net/auth/2.0",
["openid.mode"] = "checkid_setup",
["openid.return_to"] = returnUrl,
["openid.realm"] = realm,
["openid.identity"] = "http://specs.openid.net/auth/2.0/identifier_select",
["openid.claimed_id"] = "http://specs.openid.net/auth/2.0/identifier_select"
};
return Endpoint + "?" + string.Join("&", parameters.Select(pair =>
$"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
}
public async Task<string?> VerifyCallbackAsync(IQueryCollection query, string expectedReturnUrl, CancellationToken cancellationToken)
{
if (!string.Equals(query["openid.mode"], "id_res", StringComparison.Ordinal) ||
!string.Equals(query["openid.op_endpoint"].ToString().TrimEnd('/'), Endpoint.TrimEnd('/'), StringComparison.OrdinalIgnoreCase) ||
!string.Equals(query["openid.return_to"], expectedReturnUrl, StringComparison.Ordinal))
return null;
var claimedId = query["openid.claimed_id"].ToString();
if (!string.Equals(claimedId, query["openid.identity"], StringComparison.Ordinal))
return null;
var match = ClaimedIdPattern.Match(claimedId);
if (!match.Success)
return null;
var fields = query
.Where(pair => pair.Key.StartsWith("openid.", StringComparison.Ordinal))
.ToDictionary(pair => pair.Key, pair => pair.Value.ToString());
fields["openid.mode"] = "check_authentication";
var client = _httpClients.CreateClient(nameof(SteamOpenIdService));
client.Timeout = TimeSpan.FromSeconds(15);
using var response = await client.PostAsync(Endpoint, new FormUrlEncodedContent(fields), cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
var validation = await response.Content.ReadAsStringAsync(cancellationToken);
return IsValidAuthenticationResponse(validation) ? match.Groups["id"].Value : null;
}
internal static bool IsValidAuthenticationResponse(string response) =>
response.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(line => string.Equals(line, "is_valid:true", StringComparison.Ordinal));
internal static string? ExtractSteamId(string claimedId)
{
var match = ClaimedIdPattern.Match(claimedId);
return match.Success ? match.Groups["id"].Value : null;
}
}
@@ -1,5 +1,7 @@
using AuthApi.Models;
using MongoDB.Bson;
using MongoDB.Driver;
using System.Text.RegularExpressions;
namespace AuthApi.Services;
@@ -17,6 +19,12 @@ public class UserService
var keys = Builders<User>.IndexKeys.Ascending(u => u.Username);
_col.Indexes.CreateOne(new CreateIndexModel<User>(keys, new CreateIndexOptions { Unique = true }));
var steamKeys = Builders<User>.IndexKeys.Ascending(u => u.SteamId);
_col.Indexes.CreateOne(new CreateIndexModel<User>(steamKeys, new CreateIndexOptions<User>
{
Unique = true,
PartialFilterExpression = new BsonDocument("SteamId", new BsonDocument("$type", "string"))
}));
}
public async Task<User?> GetByUsernameAsync(string username) =>
@@ -28,11 +36,36 @@ public class UserService
public async Task<User?> GetByEmailAsync(string email) =>
await _col.Find(u => u.Email == email).FirstOrDefaultAsync();
public async Task<List<User>> GetByLoginCandidatesAsync(string login)
{
var normalized = login.Trim();
var usernameMatch = await GetByUsernameAsync(normalized);
if (usernameMatch is not null)
return [usernameMatch];
var emailPattern = new BsonRegularExpression($"^{Regex.Escape(normalized)}$", "i");
return await _col.Find(Builders<User>.Filter.Regex(u => u.Email, emailPattern)).ToListAsync();
}
public async Task<User?> GetBySteamIdAsync(string steamId) =>
await _col.Find(u => u.SteamId == steamId).FirstOrDefaultAsync();
public Task CreateAsync(User user) => _col.InsertOneAsync(user);
public Task UpdateAsync(User user) =>
_col.ReplaceOneAsync(u => u.Id == user.Id, user);
public async Task<bool> TryLinkSteamAsync(string userId, string steamId, string refreshToken, DateTime refreshExpiry)
{
var filter = Builders<User>.Filter.Eq(u => u.Id, userId) &
(Builders<User>.Filter.Eq(u => u.SteamId, null) | Builders<User>.Filter.Eq(u => u.SteamId, steamId));
var update = Builders<User>.Update
.Set(u => u.SteamId, steamId)
.Set(u => u.RefreshToken, refreshToken)
.Set(u => u.RefreshTokenExpiry, refreshExpiry);
var result = await _col.UpdateOneAsync(filter, update);
return result.MatchedCount == 1;
}
public Task DeleteAsync(string id) => _col.DeleteOneAsync(u => u.Id == id);
public Task<List<User>> GetAllAsync() =>