using System.Globalization; using System.Net; using System.Security.Cryptography; using Microsoft.Data.Sqlite; using SQLitePCL; namespace AuthProxy; public static class Program { private const string COOKIE_NAME = "__Secure-Token"; private static readonly string s_loginHtmlCache = File.ReadAllText(Path.Combine( AppDomain.CurrentDomain.BaseDirectory, "login.html")); private static readonly string? s_domain = Environment .GetEnvironmentVariable("AUTH_DOMAIN"); private static readonly string s_db = Environment .GetEnvironmentVariable("AUTH_DB") ?? "credentials.db"; private static readonly long s_lifetime = long.Parse(Environment .GetEnvironmentVariable("AUTH_LIFETIME") ?? "7", CultureInfo.InvariantCulture) * 60 * 60 * 24; private static readonly string? s_password = Environment .GetEnvironmentVariable("AUTH_PASSWORD"); private static readonly int s_port = int.Parse(Environment .GetEnvironmentVariable("AUTH_PORT") ?? "5000", CultureInfo.InvariantCulture); private static string ConnectionString { get => $"Data Source={s_db}"; } public static void Main(string[] args) { var app = Initialize(args); app.UseSession(); app.MapGet("/auth/check", async (context) => { var token = context.Request.Cookies[COOKIE_NAME]; if (!TokenIsValid(token)) { context.Response.ContentType = "text/plain"; context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; await context.Response.WriteAsync("unauthorized"); return; } context.Response.ContentType = "text/plain"; context.Response.StatusCode = (int)HttpStatusCode.OK; await context.Response.WriteAsync("authorized"); }); app.MapGet("/auth/login", async (context) => { context.Response.ContentType = "text/html"; context.Response.StatusCode = (int)HttpStatusCode.OK; await context.Response.WriteAsync(s_loginHtmlCache); }); app.MapGet("/auth/logout", (context) => { if (context.Request.Cookies[COOKIE_NAME] == null) return Task.CompletedTask; var cookieOpts = new CookieOptions { Path = "/", Secure = true, HttpOnly = true, SameSite = SameSiteMode.None, MaxAge = TimeSpan.FromSeconds(s_lifetime), }; if (!string.IsNullOrEmpty(s_domain)) { cookieOpts.Domain = s_domain; } context.Response.Cookies.Append(COOKIE_NAME, string.Empty, cookieOpts); context.Response.Redirect("/"); return Task.CompletedTask; }); app.MapPost("/auth/password", async (context) => { if (context.Request.Form.TryGetValue("password", out var reqPassword) && !string.IsNullOrEmpty(s_password) && string.Equals(reqPassword.FirstOrDefault(), s_password, StringComparison.Ordinal)) { var cookieOpts = new CookieOptions { Path = "/", Secure = true, HttpOnly = true, SameSite = SameSiteMode.None, MaxAge = TimeSpan.FromSeconds(s_lifetime), }; if (!string.IsNullOrEmpty(s_domain)) { cookieOpts.Domain = s_domain; } using var connection = new SqliteConnection(ConnectionString); await connection.OpenAsync(); context.Response.Cookies.Append( COOKIE_NAME, GenerateToken(connection), cookieOpts); if (!context.Request.Form.TryGetValue("target", out var target) || string.IsNullOrEmpty(target.FirstOrDefault())) { target = []; } context.Response.Redirect(target.FirstOrDefault() ?? "/"); } else { context.Response.StatusCode = 401; await context.Response.WriteAsJsonAsync(new { error = "bad password" }); } }); app.Run($"http://0.0.0.0:{s_port}"); } private static string GenerateToken(SqliteConnection connection) { var rng = RandomNumberGenerator.Create(); var bytes = new byte[32]; rng.GetBytes(bytes); var token = Convert.ToBase64String(bytes); using var cmd = new SqliteCommand(@"insert into tokens (token) values (@Token)", connection); var param = cmd.CreateParameter(); param.ParameterName = "@Token"; param.Value = token; cmd.Parameters.Add(param); cmd.ExecuteNonQuery(); return token; } private static bool TokenIsValid(string? token) { if (string.IsNullOrWhiteSpace(token)) return false; using var connection = new SqliteConnection(ConnectionString); connection.Open(); using var cmd = new SqliteCommand( @"select 1 from tokens where token=@Token and julianday(created_date, @Timeout) > julianday(CURRENT_TIMESTAMP)", connection); var tokenParam = cmd.CreateParameter(); tokenParam.ParameterName = "@Token"; tokenParam.Value = token; var timeoutParam = cmd.CreateParameter(); timeoutParam.ParameterName = "@Timeout"; timeoutParam.Value = $"+{s_lifetime} seconds"; cmd.Parameters.Add(tokenParam); cmd.Parameters.Add(timeoutParam); return cmd.ExecuteScalar() != null; } private static void RemoveExpiredTokens() { using var connection = new SqliteConnection(ConnectionString); connection.Open(); using var cmd = new SqliteCommand( @"delete from tokens where julianday(created_date, @Timeout) < julianday(CURRENT_TIMESTAMP)", connection); var timeoutParam = cmd.CreateParameter(); timeoutParam.ParameterName = "@Timeout"; timeoutParam.Value = $"+{s_lifetime} seconds"; cmd.Parameters.Add(timeoutParam); cmd.ExecuteNonQuery(); } private static WebApplication Initialize(string[] args) { Batteries.Init(); using var connection = new SqliteConnection(ConnectionString); connection.Open(); using (var cmd = new SqliteCommand( @"select 1 from sqlite_master where type='table' and name='tokens'", connection)) { var exists = cmd.ExecuteScalar(); if (exists == null) { var schemaPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "schema.sql"); if (!File.Exists(schemaPath)) { throw new FileNotFoundException(schemaPath); } var schema = File.ReadAllText(schemaPath); using var schemaCmd = new SqliteCommand(schema, connection); schemaCmd.ExecuteNonQuery(); } } RemoveExpiredTokens(); var builder = WebApplication.CreateBuilder(args); builder.Services.AddDistributedMemoryCache(); builder.Services.AddSession(); return builder.Build(); } }