using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; using Backend.Common; using Backend.Configuration; using Backend.Contracts; using Backend.Data; using Backend.Security; using Backend.Services; using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; namespace Backend.Endpoints; public static partial class AuthEndpoints { private const string TwitchLoginPurpose = "team-login"; private const string TwitchBindingPurpose = "team-binding"; private const string TwitchStateCachePrefix = "twitch-oauth-state:"; private static readonly TimeSpan TwitchStateLifetime = TimeSpan.FromMinutes(10); private static async Task StartTwitchAuthorization( HttpContext context, TwitchAuthorizeRequest request, IMemoryCache memoryCache, IOptions twitchOptions, IConfiguration configuration, IWebHostEnvironment environment, AwardsDbContext db, IUserSessionService userSessionService) { var options = await ResolveEffectiveTwitchOptionsAsync(db, twitchOptions.Value, configuration, context.RequestAborted); if (!TwitchAuthConfigured(options)) { return Results.Json( new { message = "Twitch OAuth ist noch nicht konfiguriert. Bitte TwitchAuth:ClientId und TwitchAuth:ClientSecret setzen." }, statusCode: StatusCodes.Status503ServiceUnavailable); } var purpose = NormalizeTwitchPurpose(request.Purpose); if (purpose is null) { return Results.BadRequest(new { message = "Unbekannter Twitch-Login-Zweck." }); } var frontendOrigin = NormalizeFrontendOrigin(request.FrontendOrigin, configuration, environment); if (frontendOrigin is null) { return Results.BadRequest(new { message = "Frontend-Origin ist fuer Twitch OAuth nicht erlaubt." }); } var sessionToken = string.Empty; if (purpose == TwitchBindingPurpose) { var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted); if (session is null) { return Results.Unauthorized(); } sessionToken = session.SessionToken; } var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); memoryCache.Set( $"{TwitchStateCachePrefix}{state}", new TwitchOAuthState(purpose, NormalizeReturnUrl(request.ReturnUrl), frontendOrigin, sessionToken), TwitchStateLifetime); var authorizationParams = new Dictionary { ["client_id"] = options.ClientId, ["redirect_uri"] = ResolveRedirectUri(context, options), ["response_type"] = "code", ["state"] = state, }; if (!string.IsNullOrWhiteSpace(options.Scope)) { authorizationParams["scope"] = options.Scope.Trim(); } var authorizationUrl = QueryHelpers.AddQueryString( "https://id.twitch.tv/oauth2/authorize", authorizationParams); return Results.Ok(new TwitchAuthorizeResponse(authorizationUrl)); } private static async Task CompleteTwitchAuthorization( HttpContext context, string? code, string? state, string? error, string? error_description, IMemoryCache memoryCache, IOptions twitchOptions, IConfiguration configuration, IHttpClientFactory httpClientFactory, AwardsDbContext db, IUserSessionService userSessionService) { if (string.IsNullOrWhiteSpace(state) || !memoryCache.TryGetValue($"{TwitchStateCachePrefix}{state}", out var oauthState) || oauthState is null) { return RedirectToTwitchCallbackError("/", "Der Twitch-Login ist abgelaufen. Bitte starte ihn erneut."); } memoryCache.Remove($"{TwitchStateCachePrefix}{state}"); if (!string.IsNullOrWhiteSpace(error)) { return RedirectToTwitchCallbackError( oauthState, string.IsNullOrWhiteSpace(error_description) ? "Twitch hat den Login abgebrochen." : error_description); } if (string.IsNullOrWhiteSpace(code)) { return RedirectToTwitchCallbackError(oauthState, "Twitch hat keinen Login-Code zurueckgegeben."); } var options = await ResolveEffectiveTwitchOptionsAsync(db, twitchOptions.Value, configuration, context.RequestAborted); if (!TwitchAuthConfigured(options)) { return RedirectToTwitchCallbackError(oauthState, "Twitch OAuth ist auf dem Server nicht vollstaendig konfiguriert."); } var httpClient = httpClientFactory.CreateClient(); var token = await ExchangeTwitchCodeAsync(httpClient, context, options, code, context.RequestAborted); if (token is null) { return RedirectToTwitchCallbackError(oauthState, "Twitch konnte den Login-Code nicht bestaetigen."); } var twitchUser = await LoadTwitchUserAsync(httpClient, options.ClientId, token.AccessToken, context.RequestAborted); if (twitchUser is null) { return RedirectToTwitchCallbackError(oauthState, "Twitch konnte dein Profil nicht laden."); } var twitchLogin = NormalizeTwitchUserId(twitchUser.Login); if (string.IsNullOrWhiteSpace(twitchLogin)) { return RedirectToTwitchCallbackError(oauthState, "Twitch hat keinen gueltigen Login-Namen geliefert."); } if (oauthState.Purpose == TwitchBindingPurpose) { return await CompleteTwitchBindingAsync(context, oauthState, twitchLogin, twitchUser.DisplayName, db); } return await CompleteTwitchTeamLoginAsync(context, oauthState, twitchLogin, db, userSessionService); } private static async Task CompleteTwitchBindingAsync( HttpContext context, TwitchOAuthState oauthState, string twitchLogin, string twitchDisplayName, AwardsDbContext db) { var session = await db.UserSessions.FirstOrDefaultAsync( item => item.SessionToken == oauthState.SessionToken && item.IsActive, context.RequestAborted); if (session is null) { return RedirectToTwitchCallbackError(oauthState, "Deine Team-Session ist abgelaufen. Bitte melde dich erneut an."); } var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted); if (member is null || !member.IsActive) { return RedirectToTwitchCallbackError(oauthState, "Dieser Account ist kein aktiver Team-Login."); } if (member.MustChangePassword) { return RedirectToTwitchCallbackError(oauthState, "Bitte aendere zuerst dein temporaeres Passwort."); } var alreadyBound = await db.TeamMembers.AnyAsync( item => item.Id != member.Id && item.BoundTwitchUserId == twitchLogin, context.RequestAborted); if (alreadyBound) { return RedirectToTwitchCallbackError(oauthState, "Dieser Twitch-Account ist bereits mit einem Team-Account verbunden."); } var previousTwitchUserId = member.BoundTwitchUserId; if (!string.IsNullOrWhiteSpace(previousTwitchUserId) && !string.Equals(previousTwitchUserId, twitchLogin, StringComparison.OrdinalIgnoreCase)) { foreach (var oldSession in db.UserSessions.Where(item => item.TwitchUserId == previousTwitchUserId)) { oldSession.IsActive = false; } } member.BoundTwitchUserId = twitchLogin; member.BoundTwitchDisplayName = string.IsNullOrWhiteSpace(twitchDisplayName) ? twitchLogin : twitchDisplayName; member.TwitchBoundAt = DateTimeOffset.UtcNow; member.UpdatedAt = DateTimeOffset.UtcNow; member.UpdatedByTwitchId = session.TwitchUserId; await db.SaveChangesAsync(context.RequestAborted); return RedirectToTwitchCallback(oauthState, "connected"); } private static async Task DisconnectTwitchBinding( HttpContext context, AwardsDbContext db, IUserSessionService userSessionService) { var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted); if (session is null) { return Results.Unauthorized(); } var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted); if (member is null || !member.IsActive) { return Results.BadRequest(new { message = "Dieser Account nutzt keinen aktiven Team-Login." }); } var boundTwitchUserId = NormalizeTwitchUserId(member.BoundTwitchUserId); if (string.IsNullOrWhiteSpace(boundTwitchUserId)) { return Results.Ok(new TwitchBindingDisconnectResponse( false, false, await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted))); } var currentTeamLogin = ReadTeamLoginFromSession(session.TwitchUserId); var currentSessionUsesBoundTwitch = string.IsNullOrWhiteSpace(currentTeamLogin) && string.Equals(NormalizeTwitchUserId(session.TwitchUserId), boundTwitchUserId, StringComparison.OrdinalIgnoreCase); foreach (var linkedSession in db.UserSessions.Where(item => item.TwitchUserId == boundTwitchUserId)) { linkedSession.IsActive = false; } member.BoundTwitchUserId = null; member.BoundTwitchDisplayName = null; member.TwitchBoundAt = null; member.UpdatedAt = DateTimeOffset.UtcNow; member.UpdatedByTwitchId = session.TwitchUserId; if (currentSessionUsesBoundTwitch) { session.IsActive = false; } await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new TwitchBindingDisconnectResponse( true, currentSessionUsesBoundTwitch, currentSessionUsesBoundTwitch ? null : await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted))); } private static async Task CompleteTwitchTeamLoginAsync( HttpContext context, TwitchOAuthState oauthState, string twitchLogin, AwardsDbContext db, IUserSessionService userSessionService) { var member = await db.TeamMembers.FirstOrDefaultAsync( item => item.BoundTwitchUserId == twitchLogin, context.RequestAborted); if (member is null || !member.IsActive) { return RedirectToTwitchCallbackError(oauthState, "Fuer diesen Twitch-Account ist kein aktiver Team-Account gebunden."); } member.LastLoginAt = DateTimeOffset.UtcNow; var session = await userSessionService.CreateSessionAsync( twitchLogin, member.DisplayName, member.Role, RequestMetadataReader.Read(context), context.RequestAborted); return RedirectToTwitchCallback(oauthState, "authenticated", session.SessionToken); } private static async Task ExchangeTwitchCodeAsync( HttpClient httpClient, HttpContext context, TwitchAuthOptions options, string code, CancellationToken cancellationToken) { using var response = await httpClient.PostAsync( "https://id.twitch.tv/oauth2/token", new FormUrlEncodedContent(new Dictionary { ["client_id"] = options.ClientId, ["client_secret"] = options.ClientSecret, ["code"] = code, ["grant_type"] = "authorization_code", ["redirect_uri"] = ResolveRedirectUri(context, options), }), cancellationToken); if (!response.IsSuccessStatusCode) { return null; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); return await JsonSerializer.DeserializeAsync(stream, cancellationToken: cancellationToken); } private static async Task LoadTwitchUserAsync( HttpClient httpClient, string clientId, string accessToken, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.twitch.tv/helix/users"); request.Headers.Add("Client-Id", clientId); request.Headers.Authorization = new("Bearer", accessToken); using var response = await httpClient.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { return null; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); var payload = await JsonSerializer.DeserializeAsync(stream, cancellationToken: cancellationToken); return payload?.Data.FirstOrDefault(); } private static IResult RedirectToTwitchCallback(TwitchOAuthState state, string status, string? sessionToken = null, string? message = null) { var values = new Dictionary { ["status"] = status, ["returnUrl"] = state.ReturnUrl, }; if (!string.IsNullOrWhiteSpace(sessionToken)) { values["sessionToken"] = sessionToken; } if (!string.IsNullOrWhiteSpace(message)) { values["message"] = message; } var fragment = string.Join('&', values.Select(item => $"{item.Key}={Uri.EscapeDataString(item.Value)}")); return Results.Redirect($"{state.FrontendOrigin}/auth/twitch/callback#{fragment}"); } private static IResult RedirectToTwitchCallbackError(TwitchOAuthState state, string message) => RedirectToTwitchCallback(state, "error", message: message); private static IResult RedirectToTwitchCallbackError(string returnUrl, string message) => RedirectToTwitchCallback(new TwitchOAuthState(TwitchLoginPurpose, NormalizeReturnUrl(returnUrl), ApplicationDefaults.FrontendOrigins[0], string.Empty), "error", message: message); private static bool TwitchAuthConfigured(TwitchAuthOptions options) => !string.IsNullOrWhiteSpace(options.ClientId) && !string.IsNullOrWhiteSpace(options.ClientSecret); private static async Task ResolveEffectiveTwitchOptionsAsync( AwardsDbContext db, TwitchAuthOptions configuredOptions, IConfiguration configuration, CancellationToken cancellationToken) { var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, cancellationToken); return new TwitchAuthOptions { ClientId = FirstConfigured(settings?.TwitchClientId, ReadTwitchSetting(configuration, "ClientId", "VTSA_TWITCH_CLIENT_ID"), configuredOptions.ClientId), ClientSecret = FirstConfigured(settings?.TwitchClientSecret, ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"), configuredOptions.ClientSecret), RedirectUri = FirstConfigured(settings?.TwitchRedirectUri, ReadTwitchSetting(configuration, "RedirectUri", "VTSA_TWITCH_REDIRECT_URI"), configuredOptions.RedirectUri), Scope = FirstConfigured(settings?.TwitchScope, ReadTwitchSetting(configuration, "Scope", "VTSA_TWITCH_SCOPE"), configuredOptions.Scope), }; } private static string FirstConfigured(params string?[] values) => values.Select(value => value?.Trim() ?? string.Empty).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; private static string ReadTwitchSetting(IConfiguration configuration, string key, string environmentKey) => configuration[environmentKey] ?? configuration[$"TwitchAuth:{key}"] ?? string.Empty; private static string ResolveRedirectUri(HttpContext context, TwitchAuthOptions options) { if (!string.IsNullOrWhiteSpace(options.RedirectUri)) { return options.RedirectUri.Trim(); } return $"{context.Request.Scheme}://{context.Request.Host}/api/auth/twitch/callback"; } private static string? NormalizeTwitchPurpose(string? purpose) { var normalized = (purpose ?? string.Empty).Trim().ToLowerInvariant(); return normalized is TwitchLoginPurpose or TwitchBindingPurpose ? normalized : null; } private static string NormalizeReturnUrl(string? returnUrl) { var normalized = (returnUrl ?? "/admin").Trim(); return normalized.StartsWith("/", StringComparison.Ordinal) && !normalized.StartsWith("//", StringComparison.Ordinal) && !normalized.StartsWith("/auth/twitch/callback", StringComparison.OrdinalIgnoreCase) ? normalized : "/admin"; } private static string? NormalizeFrontendOrigin( string? frontendOrigin, IConfiguration configuration, IWebHostEnvironment environment) { if (!Uri.TryCreate(frontendOrigin?.Trim(), UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https") || string.IsNullOrWhiteSpace(uri.Host)) { return null; } var origin = uri.GetLeftPart(UriPartial.Authority); var configuredOrigins = configuration .GetSection(FrontendOptions.SectionName) .Get()? .AllowedOrigins ?? []; var allowedOrigins = configuredOrigins.Length > 0 || !environment.IsDevelopment() ? configuredOrigins : ApplicationDefaults.FrontendOrigins; return allowedOrigins.Any(item => string.Equals(item.TrimEnd('/'), origin, StringComparison.OrdinalIgnoreCase)) ? origin : null; } private sealed record TwitchOAuthState( string Purpose, string ReturnUrl, string FrontendOrigin, string SessionToken); private sealed record TwitchTokenResponse( [property: JsonPropertyName("access_token")] string AccessToken); private sealed record TwitchUsersResponse( [property: JsonPropertyName("data")] TwitchUserResponseItem[] Data); private sealed record TwitchUserResponseItem( [property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("login")] string Login, [property: JsonPropertyName("display_name")] string DisplayName); }