From e3d9c8f2736fb0a947626547eef87ae598e8d37f Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Tue, 21 Apr 2026 16:13:26 -0600 Subject: [PATCH] Add sso for other websites --- Media.JoshHeaps.Net/Api/SsoApi.cs | 175 ++++++++++++++++++ .../Database/030_sso_authorization_codes.sql | 15 ++ Media.JoshHeaps.Net/Pages/Login.cshtml | 4 + Media.JoshHeaps.Net/Pages/Login.cshtml.cs | 10 +- .../Pages/Sso/Authorize.cshtml | 5 + .../Pages/Sso/Authorize.cshtml.cs | 59 ++++++ .../Services/SsoClientRegistry.cs | 21 +++ Media.JoshHeaps.Net/appsettings.json | 11 ++ 8 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 Media.JoshHeaps.Net/Api/SsoApi.cs create mode 100644 Media.JoshHeaps.Net/Database/030_sso_authorization_codes.sql create mode 100644 Media.JoshHeaps.Net/Pages/Sso/Authorize.cshtml create mode 100644 Media.JoshHeaps.Net/Pages/Sso/Authorize.cshtml.cs create mode 100644 Media.JoshHeaps.Net/Services/SsoClientRegistry.cs diff --git a/Media.JoshHeaps.Net/Api/SsoApi.cs b/Media.JoshHeaps.Net/Api/SsoApi.cs new file mode 100644 index 0000000..07be226 --- /dev/null +++ b/Media.JoshHeaps.Net/Api/SsoApi.cs @@ -0,0 +1,175 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using Npgsql; + +namespace Media.JoshHeaps.Net.Api; + +[ApiController] +[Route("sso")] +public class SsoApi(DbExecutor db, IConfiguration config, ILogger logger) : ControllerBase +{ + [HttpPost("token")] + public async Task Exchange([FromBody] SsoTokenRequest request) + { + if (request == null || string.IsNullOrWhiteSpace(request.ClientId) || string.IsNullOrWhiteSpace(request.Code)) + { + return BadRequest(new { error = "client_id and code are required" }); + } + + if (!Request.Headers.TryGetValue("X-Client-Secret", out var providedSecret) || string.IsNullOrWhiteSpace(providedSecret)) + { + return Unauthorized(new { error = "missing client credentials" }); + } + + var client = SsoClientRegistry.Find(config, request.ClientId); + if (client == null || !BCrypt.Net.BCrypt.Verify(providedSecret!, client.ClientSecretHash)) + { + logger.LogWarning("SSO token exchange failed: bad client credentials for {ClientId}", request.ClientId); + return Unauthorized(new { error = "invalid client credentials" }); + } + + var codeHash = HashCode(request.Code); + var row = await ConsumeCodeAsync(codeHash); + if (row == null) + { + return BadRequest(new { error = "invalid, expired, or already-used code" }); + } + + if (!string.Equals(row.Value.ClientId, request.ClientId, StringComparison.Ordinal)) + { + return BadRequest(new { error = "code was issued for a different client" }); + } + + if (!client.AllowsRedirectUri(row.Value.RedirectUri)) + { + return BadRequest(new { error = "redirect_uri mismatch" }); + } + + var user = await LoadUserAsync(row.Value.UserId); + if (user == null) + { + return BadRequest(new { error = "user no longer exists" }); + } + + var jwt = IssueToken(user, request.ClientId); + return Ok(new SsoTokenResponse + { + AccessToken = jwt, + TokenType = "Bearer", + ExpiresIn = 300 + }); + } + + private async Task<(long UserId, string ClientId, string RedirectUri)?> ConsumeCodeAsync(string codeHash) + { + var connectionString = config["connectionString"]!; + await using var conn = new NpgsqlConnection(connectionString); + await conn.OpenAsync(); + await using var tx = await conn.BeginTransactionAsync(); + + long userId; + string clientId; + string redirectUri; + + await using (var select = new NpgsqlCommand( + @"SELECT user_id, client_id, redirect_uri + FROM app.sso_authorization_codes + WHERE code_hash = @h + AND consumed_at IS NULL + AND expires_at > NOW() + FOR UPDATE", conn, tx)) + { + select.Parameters.AddWithValue("@h", codeHash); + await using var reader = await select.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return null; + userId = reader.GetInt64(0); + clientId = reader.GetString(1); + redirectUri = reader.GetString(2); + } + + await using (var update = new NpgsqlCommand( + "UPDATE app.sso_authorization_codes SET consumed_at = NOW() WHERE code_hash = @h", + conn, tx)) + { + update.Parameters.AddWithValue("@h", codeHash); + await update.ExecuteNonQueryAsync(); + } + + await tx.CommitAsync(); + return (userId, clientId, redirectUri); + } + + private async Task LoadUserAsync(long userId) + { + return await db.ExecuteReaderAsync( + "SELECT id, email, username, email_verified FROM app.users WHERE id = @userId AND is_active = true", + reader => new SsoUser + { + Id = reader.GetInt64(0), + Email = reader.GetString(1), + Username = reader.GetString(2), + EmailVerified = reader.GetBoolean(3) + }, + new { userId }); + } + + private string IssueToken(SsoUser user, string audience) + { + var jwtKey = config["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured"); + var jwtIssuer = config["Jwt:Issuer"] ?? throw new InvalidOperationException("JWT Issuer not configured"); + + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Name, user.Username), + new Claim("EmailVerified", user.EmailVerified.ToString()), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")) + }; + + var token = new JwtSecurityToken( + issuer: jwtIssuer, + audience: audience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(5), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private static string HashCode(string code) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(code)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} + +public sealed class SsoTokenRequest +{ + public string ClientId { get; set; } = string.Empty; + public string Code { get; set; } = string.Empty; +} + +public sealed class SsoTokenResponse +{ + public string AccessToken { get; set; } = string.Empty; + public string TokenType { get; set; } = "Bearer"; + public int ExpiresIn { get; set; } +} + +internal sealed class SsoUser +{ + public long Id { get; set; } + public string Email { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public bool EmailVerified { get; set; } +} diff --git a/Media.JoshHeaps.Net/Database/030_sso_authorization_codes.sql b/Media.JoshHeaps.Net/Database/030_sso_authorization_codes.sql new file mode 100644 index 0000000..f809010 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/030_sso_authorization_codes.sql @@ -0,0 +1,15 @@ +-- SSO authorization codes for the OAuth2 authorization-code flow. +-- The raw code is never stored; we persist the SHA-256 hash only. +-- Codes are single-use and short-lived (see Sso:CodeLifetimeSeconds in config). + +CREATE TABLE IF NOT EXISTS app.sso_authorization_codes ( + code_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE, + redirect_uri TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_sso_codes_expires ON app.sso_authorization_codes(expires_at); diff --git a/Media.JoshHeaps.Net/Pages/Login.cshtml b/Media.JoshHeaps.Net/Pages/Login.cshtml index 6e0637e..45f2cc9 100644 --- a/Media.JoshHeaps.Net/Pages/Login.cshtml +++ b/Media.JoshHeaps.Net/Pages/Login.cshtml @@ -43,6 +43,10 @@
@Html.AntiForgeryToken() + @if (!string.IsNullOrEmpty(Model.ReturnUrl)) + { + + }
+ !string.IsNullOrWhiteSpace(ReturnUrl) && Url.IsLocalUrl(ReturnUrl) ? ReturnUrl : null; } diff --git a/Media.JoshHeaps.Net/Pages/Sso/Authorize.cshtml b/Media.JoshHeaps.Net/Pages/Sso/Authorize.cshtml new file mode 100644 index 0000000..e2f7b2f --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/Sso/Authorize.cshtml @@ -0,0 +1,5 @@ +@page "/sso/authorize" +@model Media.JoshHeaps.Net.Pages.Sso.AuthorizeModel +@{ + Layout = null; +} diff --git a/Media.JoshHeaps.Net/Pages/Sso/Authorize.cshtml.cs b/Media.JoshHeaps.Net/Pages/Sso/Authorize.cshtml.cs new file mode 100644 index 0000000..d585144 --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/Sso/Authorize.cshtml.cs @@ -0,0 +1,59 @@ +using System.Security.Cryptography; +using System.Text; +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Media.JoshHeaps.Net.Pages.Sso; + +public class AuthorizeModel(DbExecutor db, IConfiguration config, ILogger logger) : AuthenticatedPageModel +{ + public async Task OnGetAsync( + [FromQuery(Name = "client_id")] string? clientId, + [FromQuery(Name = "redirect_uri")] string? redirectUri, + [FromQuery] string? state) + { + if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(redirectUri) || string.IsNullOrWhiteSpace(state)) + { + return BadRequest("client_id, redirect_uri, and state are required"); + } + + var client = SsoClientRegistry.Find(config, clientId); + if (client == null) return BadRequest("unknown client_id"); + if (!client.AllowsRedirectUri(redirectUri)) return BadRequest("redirect_uri is not registered for this client"); + + if (!IsAuthenticated()) + { + var original = $"/sso/authorize?client_id={Uri.EscapeDataString(clientId)}&redirect_uri={Uri.EscapeDataString(redirectUri)}&state={Uri.EscapeDataString(state)}"; + return Redirect($"/Login?ReturnUrl={Uri.EscapeDataString(original)}"); + } + + LoadUserSession(); + + var code = GenerateCode(); + var codeHash = HashCode(code); + var lifetime = int.TryParse(config["Sso:CodeLifetimeSeconds"], out var s) ? s : 60; + var expiresAt = DateTimeOffset.UtcNow.AddSeconds(lifetime); + + await db.ExecuteNonQueryAsync( + @"INSERT INTO app.sso_authorization_codes (code_hash, client_id, user_id, redirect_uri, expires_at) + VALUES (@codeHash, @clientId, @userId, @redirectUri, @expiresAt)", + new { codeHash, clientId, userId = UserId, redirectUri, expiresAt }); + + logger.LogInformation("SSO code issued for user {UserId} to client {ClientId}", UserId, clientId); + + var separator = redirectUri.Contains('?') ? '&' : '?'; + return Redirect($"{redirectUri}{separator}code={Uri.EscapeDataString(code)}&state={Uri.EscapeDataString(state)}"); + } + + private static string GenerateCode() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes).Replace("+", "-").Replace("/", "_").TrimEnd('='); + } + + private static string HashCode(string code) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(code)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} diff --git a/Media.JoshHeaps.Net/Services/SsoClientRegistry.cs b/Media.JoshHeaps.Net/Services/SsoClientRegistry.cs new file mode 100644 index 0000000..72ad032 --- /dev/null +++ b/Media.JoshHeaps.Net/Services/SsoClientRegistry.cs @@ -0,0 +1,21 @@ +namespace Media.JoshHeaps.Net.Services; + +public sealed class SsoClientConfig +{ + public string ClientId { get; set; } = string.Empty; + public string ClientSecretHash { get; set; } = string.Empty; + public List RedirectUris { get; set; } = []; + public string Name { get; set; } = string.Empty; + + public bool AllowsRedirectUri(string uri) => + RedirectUris.Any(r => string.Equals(r, uri, StringComparison.Ordinal)); +} + +public static class SsoClientRegistry +{ + public static SsoClientConfig? Find(IConfiguration config, string clientId) + { + var clients = config.GetSection("Sso:Clients").Get>() ?? []; + return clients.FirstOrDefault(c => string.Equals(c.ClientId, clientId, StringComparison.Ordinal)); + } +} diff --git a/Media.JoshHeaps.Net/appsettings.json b/Media.JoshHeaps.Net/appsettings.json index 852491e..5cf3a3e 100644 --- a/Media.JoshHeaps.Net/appsettings.json +++ b/Media.JoshHeaps.Net/appsettings.json @@ -24,6 +24,17 @@ "InvalidateUrl": "https://joshheaps.net/api/blog/invalidate", "InvalidateKey": "CHANGE_ME" }, + "Sso": { + "CodeLifetimeSeconds": 60, + "Clients": [ + { + "ClientId": "ai", + "ClientSecretHash": "SET_VIA_USER_SECRETS", + "RedirectUris": [ "https://ai.joshheaps.net/auth/callback" ], + "Name": "AI JoshHeaps" + } + ] + }, "FileUpload": { "MaxFileSizeMB": 10, "AllowedImageTypes": ["image/jpeg", "image/png", "image/gif", "image/webp"],