diff --git a/.gitea/scripts/deploy-environment.sh b/.gitea/scripts/deploy-environment.sh index d0823b4..658bd56 100644 --- a/.gitea/scripts/deploy-environment.sh +++ b/.gitea/scripts/deploy-environment.sh @@ -57,12 +57,17 @@ kubectl create secret generic step3-runtime-secrets \ --from-literal=purge-secret="${INTERNAL_PURGE_SECRET}" \ --from-literal=stripe-secret-key="${STRIPE_SECRET_KEY}" \ --from-literal=stripe-webhook-secret="${STRIPE_WEBHOOK_SECRET}" \ + --from-literal=google-client-id="${GOOGLE_CLIENT_ID:-}" \ + --from-literal=google-client-secret="${GOOGLE_CLIENT_SECRET:-}" \ --dry-run=client -o yaml | kubectl apply -n "${KUBE_NAMESPACE}" -f - kubectl kustomize "k8s/overlays/${DEPLOYMENT_ENVIRONMENT}" \ | sed "s|step3-questions:latest|${API_IMAGE}|g; s|step3-web:latest|${WEB_IMAGE}|g" \ | kubectl apply -f - +# Secret and ConfigMap values are consumed as environment variables at pod startup. +# Force a rollout so rerunning an unchanged revision still applies rotated secrets. +kubectl rollout restart deployment/step3-api -n "${KUBE_NAMESPACE}" kubectl rollout status deployment/step3-api -n "${KUBE_NAMESPACE}" --timeout=240s kubectl get --raw "/api/v1/namespaces/${KUBE_NAMESPACE}/services/step3-api:http/proxy/readyz" >/dev/null kubectl rollout status deployment/step3-web -n "${KUBE_NAMESPACE}" --timeout=240s diff --git a/.gitea/workflows/deploy-prd.yml b/.gitea/workflows/deploy-prd.yml index b3e744e..c45c9e3 100644 --- a/.gitea/workflows/deploy-prd.yml +++ b/.gitea/workflows/deploy-prd.yml @@ -37,5 +37,6 @@ jobs: INTERNAL_PURGE_SECRET: ${{ secrets.PRD_INTERNAL_PURGE_SECRET }} STRIPE_SECRET_KEY: ${{ secrets.PRD_STRIPE_SECRET_KEY }} STRIPE_WEBHOOK_SECRET: ${{ secrets.PRD_STRIPE_WEBHOOK_SECRET }} + GOOGLE_CLIENT_ID: ${{ secrets.PRD_GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.PRD_GOOGLE_CLIENT_SECRET }} run: bash .gitea/scripts/deploy-environment.sh - diff --git a/.gitea/workflows/deploy-uat.yml b/.gitea/workflows/deploy-uat.yml index 47e4548..aea475c 100644 --- a/.gitea/workflows/deploy-uat.yml +++ b/.gitea/workflows/deploy-uat.yml @@ -37,5 +37,6 @@ jobs: INTERNAL_PURGE_SECRET: ${{ secrets.UAT_INTERNAL_PURGE_SECRET }} STRIPE_SECRET_KEY: ${{ secrets.UAT_STRIPE_SECRET_KEY }} STRIPE_WEBHOOK_SECRET: ${{ secrets.UAT_STRIPE_WEBHOOK_SECRET }} + GOOGLE_CLIENT_ID: ${{ secrets.UAT_GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.UAT_GOOGLE_CLIENT_SECRET }} run: bash .gitea/scripts/deploy-environment.sh - diff --git a/api/QuestionsApi.Tests/AuthTests.cs b/api/QuestionsApi.Tests/AuthTests.cs index 539e352..36417e9 100644 --- a/api/QuestionsApi.Tests/AuthTests.cs +++ b/api/QuestionsApi.Tests/AuthTests.cs @@ -86,6 +86,53 @@ public sealed class AuthTests Assert.Null(SteamOpenIdService.ExtractSteamId("https://steamcommunity.com/openid/id/123")); } + [Fact] + public void GoogleProviderIsDisabledUntilAllSecureConfigurationExists() + { + var incomplete = new GoogleExternalAuthProvider(new TestHttpClientFactory(), Configuration(new Dictionary + { + ["Google:ClientId"] = "client-id", + ["Google:ReturnUrl"] = "https://example.test/api/Auth/external/google/callback" + })); + var complete = new GoogleExternalAuthProvider(new TestHttpClientFactory(), Configuration(new Dictionary + { + ["Google:ClientId"] = "client-id", + ["Google:ClientSecret"] = "client-secret", + ["Google:ReturnUrl"] = "https://example.test/api/Auth/external/google/callback" + })); + + Assert.False(incomplete.IsConfigured); + Assert.True(complete.IsConfigured); + } + + [Fact] + public void GoogleAuthorizationUsesStateNonceAndPkce() + { + var provider = new GoogleExternalAuthProvider(new TestHttpClientFactory(), Configuration(new Dictionary + { + ["Google:ClientId"] = "client-id", + ["Google:ClientSecret"] = "client-secret", + ["Google:ReturnUrl"] = "https://example.test/api/Auth/external/google/callback" + })); + var url = provider.BuildAuthorizationUrl(new ExternalAuthTransaction + { + Id = "transaction-state", + Nonce = "transaction-nonce", + CodeVerifier = "a-long-test-code-verifier" + }); + + Assert.Contains("state=transaction-state", url); + Assert.Contains("nonce=transaction-nonce", url); + Assert.Contains("code_challenge_method=S256", url); + Assert.Contains("scope=openid%20email%20profile", url); + Assert.DoesNotContain("client-secret", url); + } + private static IConfiguration Configuration(Dictionary values) => new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + + private sealed class TestHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(); + } } diff --git a/api/QuestionsApi/Controllers/AuthController.cs b/api/QuestionsApi/Controllers/AuthController.cs index c936867..bfa79c2 100644 --- a/api/QuestionsApi/Controllers/AuthController.cs +++ b/api/QuestionsApi/Controllers/AuthController.cs @@ -20,6 +20,9 @@ public sealed class AuthController( UserProfileStore profiles, PasswordResetStore passwordResets, SteamAuthTransactionStore steamTransactions, + ExternalIdentityStore externalIdentities, + ExternalAuthTransactionStore externalTransactions, + IEnumerable externalProviders, PasswordResetCodeService resetCodes, IPasswordResetEmailSender resetEmail, IConfiguration configuration, @@ -109,7 +112,7 @@ public sealed class AuthController( [HttpGet("me")] [Authorize(Roles = "USER,SUPER")] public async Task Me() => - await CurrentUserAsync() is { } user ? Ok(ProfileResponse(user)) : Unauthorized(); + await CurrentUserAsync() is { } user ? Ok(await ProfileResponseAsync(user)) : Unauthorized(); [HttpPost("legal/accept")] [Authorize(Roles = "USER,SUPER")] @@ -152,7 +155,7 @@ public sealed class AuthController( { return Conflict("Email address is already in use."); } - return Ok(ProfileResponse(user)); + return Ok(await ProfileResponseAsync(user)); } [HttpPost("me/change-password")] @@ -186,6 +189,7 @@ public sealed class AuthController( await profiles.PurgeAsync(user.Id); await passwordResets.DeleteForUserAsync(user.Id); await steamTransactions.DeleteForUserAsync(user.Id); + await externalTransactions.DeleteForUserAsync(user.Id); await RevokeCurrentTokenAsync(); await users.DeleteAsync(user.Id); return NoContent(); @@ -302,6 +306,20 @@ public sealed class AuthController( legal = legal.Status(user) }; + private async Task ProfileResponseAsync(AuthUser user) => new + { + userId = user.Id, + user.Username, + user.DisplayName, + user.Email, + user.Role, + createdUtc = user.CreatedUtc ?? CreationDate(user), + connectedProviders = await externalIdentities.ProvidersForUserAsync(user.Id), + availableProviders = externalProviders.Where(provider => provider.IsConfigured) + .Select(provider => provider.Id).OrderBy(provider => provider), + legal = legal.Status(user) + }; + private object SessionResponse(AuthUser user, string accessToken, DateTime expiresUtc, string? jti = null, string? refreshToken = null) => new { diff --git a/api/QuestionsApi/Controllers/ExternalAuthController.cs b/api/QuestionsApi/Controllers/ExternalAuthController.cs new file mode 100644 index 0000000..f9ad785 --- /dev/null +++ b/api/QuestionsApi/Controllers/ExternalAuthController.cs @@ -0,0 +1,296 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using MongoDB.Driver; +using QuestionsApi.Models; +using QuestionsApi.Services; +using System.Security.Claims; +using System.Security.Cryptography; + +namespace QuestionsApi.Controllers; + +[ApiController] +[Route("api/Auth/external")] +public sealed class ExternalAuthController( + IEnumerable providerServices, + ExternalIdentityStore identities, + ExternalAuthTransactionStore transactions, + AuthUserStore users, + JwtTokenService tokens, + LegalPolicy legal, + IConfiguration configuration, + ILogger logger) : ControllerBase +{ + private readonly Dictionary _providers = providerServices + .ToDictionary(value => value.Id, StringComparer.OrdinalIgnoreCase); + + [HttpGet("providers")] + [AllowAnonymous] + public IActionResult Providers() => Ok(new + { + providers = _providers.Values.Where(value => value.IsConfigured) + .OrderBy(value => value.DisplayName) + .Select(value => new { id = value.Id, name = value.DisplayName }) + }); + + [HttpPost("{provider}/start")] + [AllowAnonymous] + [EnableRateLimiting("external-start")] + public async Task Start(string provider, ExternalAuthStartRequest? request) + { + if (!TryProvider(provider, out var service)) return NotFound(new { message = "Provider is unavailable." }); + var purpose = (request?.Purpose ?? "login").Trim().ToLowerInvariant(); + if (purpose is not ("login" or "link")) return BadRequest(new { message = "Purpose must be login or link." }); + string? userId = null; + if (purpose == "link") + { + userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (await identities.GetForUserAsync(userId, service.Id) is not null) + return Conflict(new { message = $"This account is already connected to {service.DisplayName}." }); + } + + var now = DateTime.UtcNow; + var lifetime = Math.Clamp(configuration.GetValue("ExternalAuth:TransactionTtlMinutes", 5), 2, 15); + var transaction = new ExternalAuthTransaction + { + Id = Base64Url(RandomNumberGenerator.GetBytes(32)), + Provider = service.Id, + Purpose = purpose, + UserId = userId, + Nonce = GoogleExternalAuthProvider.NewNonce(), + CodeVerifier = GoogleExternalAuthProvider.NewVerifier(), + CreatedUtc = now, + ExpiresUtc = now.AddMinutes(lifetime) + }; + await transactions.CreateAsync(transaction); + return Ok(new + { + transactionId = transaction.Id, + provider = service.Id, + authUrl = service.BuildAuthorizationUrl(transaction), + transaction.ExpiresUtc + }); + } + + [HttpGet("{provider}/callback")] + [AllowAnonymous] + [EnableRateLimiting("external-complete")] + public async Task Callback( + string provider, + [FromQuery] string? transactionId, + [FromQuery] string? state, + CancellationToken cancellationToken) + { + if (!TryProvider(provider, out var service)) + return CallbackPage(false, "External sign-in", "This provider is unavailable."); + var callbackTransactionId = string.IsNullOrWhiteSpace(transactionId) ? state : transactionId; + var transaction = await ActiveTransactionAsync(callbackTransactionId ?? ""); + if (transaction is null || transaction.Status != "pending" || transaction.Provider != service.Id) + return CallbackPage(false, service.DisplayName, "This sign-in request has expired. Return to RanaZe and try again."); + var claims = await service.VerifyCallbackAsync(Request.Query, transaction, cancellationToken); + if (claims is null) + { + logger.LogWarning("{Provider} external identity verification failed", service.Id); + return CallbackPage(false, service.DisplayName, $"{service.DisplayName} could not verify this sign-in."); + } + + if (transaction.Purpose == "link") + { + if (string.IsNullOrWhiteSpace(transaction.UserId) || + !await identities.TryLinkAsync(transaction.UserId, service.Id, claims) || + !await transactions.MarkVerifiedAsync(transaction.Id, claims, "linked", transaction.UserId)) + return CallbackPage(false, service.DisplayName, + $"That {service.DisplayName} identity is already connected to another account."); + return CallbackPage(true, service.DisplayName, $"{service.DisplayName} is now connected to your RanaZe account."); + } + + var identity = await identities.GetAsync(service.Id, claims.Subject); + var user = identity is null ? null : await users.GetByIdAsync(identity.UserId); + var status = user is null ? "requires_account" : "complete"; + if (!await transactions.MarkVerifiedAsync(transaction.Id, claims, status, user?.Id)) + return CallbackPage(false, service.DisplayName, "This sign-in request has already been used."); + if (user is not null) + { + await identities.TouchAsync(identity!.Id); + user.RefreshToken = JwtTokenService.NewRefreshToken(); + user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7); + await users.UpdateAsync(user); + } + return CallbackPage(true, service.DisplayName, + $"{service.DisplayName} verified your identity. You can return to RanaZe."); + } + + [HttpGet("{provider}/status/{transactionId}")] + [AllowAnonymous] + [EnableRateLimiting("external-poll")] + public async Task Status(string provider, string transactionId) + { + Response.Headers.CacheControl = "no-store"; + if (!TryProvider(provider, out var service)) return NotFound(new { message = "Provider is unavailable." }); + var transaction = await ActiveTransactionAsync(transactionId); + if (transaction is null || transaction.Provider != service.Id) + return NotFound(new { message = $"{service.DisplayName} sign-in expired." }); + if (transaction.Status == "requires_account") + return Ok(new + { + status = transaction.Status, + provider = service.Id, + providerName = service.DisplayName, + email = transaction.EmailVerified ? transaction.ProviderEmail : null, + emailVerified = transaction.EmailVerified + }); + if (transaction.Status is "pending" or "linked" or "failed") + return Ok(new { status = transaction.Status, provider = service.Id, message = transaction.Error }); + if (transaction.Status != "complete" || string.IsNullOrWhiteSpace(transaction.UserId)) + return Conflict(new { message = $"{service.DisplayName} sign-in is incomplete." }); + + var user = await users.GetByIdAsync(transaction.UserId); + if (user is null || transaction.Subject is null || + await identities.GetAsync(service.Id, transaction.Subject) is not { } identity || + identity.UserId != user.Id) + return Conflict(new { message = $"{service.DisplayName} account link is no longer valid." }); + if (string.IsNullOrWhiteSpace(user.RefreshToken) || user.RefreshTokenExpiry <= DateTime.UtcNow) + { + user.RefreshToken = JwtTokenService.NewRefreshToken(); + user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7); + await users.UpdateAsync(user); + } + var (accessToken, jti, expiresUtc) = tokens.Generate(user); + return Ok(new + { + status = "complete", + accessToken, + refreshToken = user.RefreshToken, + user.Username, + user.Role, + jti, + exp = expiresUtc, + legal = legal.Status(user) + }); + } + + [HttpPost("{provider}/complete")] + [AllowAnonymous] + [EnableRateLimiting("external-complete")] + public async Task Complete(string provider, CompleteExternalAuthRequest request) + { + if (!TryProvider(provider, out var service)) return NotFound(new { message = "Provider is unavailable." }); + var transaction = await ActiveTransactionAsync(request.TransactionId); + if (transaction?.Status == "complete") return Ok(new { status = "complete" }); + if (transaction is null || transaction.Provider != service.Id || transaction.Status != "requires_account" || + string.IsNullOrWhiteSpace(transaction.Subject)) + return BadRequest(new { message = $"{service.DisplayName} sign-in is not ready or has expired." }); + if (await identities.GetAsync(service.Id, transaction.Subject) is not null) + return Conflict(new { message = $"That {service.DisplayName} identity is already connected." }); + + var claims = new ExternalProviderClaims( + transaction.Subject, transaction.ProviderEmail, transaction.EmailVerified); + AuthUser user; + var createdUser = false; + if (request.Action == "create") + { + if (!request.AcceptLegal) + return BadRequest(new { message = "You must accept the current legal agreements." }); + var username = request.Username.Trim(); + var email = service.Id == ExternalAuthProviders.Google + ? AuthValidation.NormalizeEmail(transaction.ProviderEmail ?? "") + : AuthValidation.NormalizeEmail(request.Email ?? ""); + if (service.Id == ExternalAuthProviders.Google && !transaction.EmailVerified) + return BadRequest(new { message = "Google did not provide a verified email address." }); + if (AuthValidation.ValidateNewAccount(username, email, request.Password) is { } validation) + return BadRequest(new { message = validation }); + if (await users.GetByUsernameAsync(username) is not null) + return Conflict(new { message = "Username already exists." }); + if (await users.EmailExistsAsync(email)) + return Conflict(new + { + message = "That email already belongs to a RanaZe account. Link the existing account with its password." + }); + user = new AuthUser + { + Username = username, + Email = email, + NormalizedEmail = email, + PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), + Role = "USER", + CreatedUtc = DateTime.UtcNow + }; + legal.Accept(user, $"{service.Id}_registration"); + try + { + await users.CreateAsync(user); + createdUser = true; + } + catch (MongoWriteException exception) when ( + exception.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + return Conflict(new { message = "That username or email is already in use." }); + } + } + else if (request.Action == "link") + { + var candidates = await users.GetByLoginCandidatesAsync(request.Username); + user = candidates.FirstOrDefault(candidate => + BCrypt.Net.BCrypt.Verify(request.Password, candidate.PasswordHash))!; + if (user is null) return Unauthorized(new { message = "The account credentials are incorrect." }); + if (await identities.GetForUserAsync(user.Id, service.Id) is not null) + return Conflict(new { message = $"That RanaZe account already has a {service.DisplayName} identity." }); + } + else + { + return BadRequest(new { message = "Action must be create or link." }); + } + + if (!await identities.TryLinkAsync(user.Id, service.Id, claims)) + { + if (createdUser) await users.DeleteAsync(user.Id); + return Conflict(new { message = $"That {service.DisplayName} identity is already connected." }); + } + if (!await transactions.MarkCompleteAsync(transaction.Id, transaction.Subject, user.Id)) + { + if (createdUser) + { + await identities.Collection.DeleteManyAsync(value => value.UserId == user.Id); + await users.DeleteAsync(user.Id); + } + return Conflict(new { message = "This external sign-in was already completed." }); + } + return Ok(new { status = "complete" }); + } + + private bool TryProvider(string provider, out IExternalAuthProvider service) + { + if (_providers.TryGetValue(provider.Trim(), out service!) && service.IsConfigured) return true; + service = null!; + return false; + } + + private async Task ActiveTransactionAsync(string id) + { + if (string.IsNullOrWhiteSpace(id) || id.Length > 128) return null; + var transaction = await transactions.GetAsync(id); + return transaction is not null && transaction.ExpiresUtc > DateTime.UtcNow ? transaction : null; + } + + private ContentResult CallbackPage(bool success, string provider, string message) + { + Response.Headers.CacheControl = "no-store"; + Response.Headers.ContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'"; + Response.Headers.XFrameOptions = "DENY"; + Response.Headers["Referrer-Policy"] = "no-referrer"; + var color = success ? "#22c55e" : "#ef4444"; + var safeProvider = System.Net.WebUtility.HtmlEncode(provider); + var safeMessage = System.Net.WebUtility.HtmlEncode(message); + var html = $$""" + + RanaZe {{safeProvider}} sign-in +

{{safeProvider}} sign-in {{(success ? "complete" : "failed")}}

{{safeMessage}}

+ + """; + return Content(html, "text/html; charset=utf-8"); + } + + private static string Base64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} diff --git a/api/QuestionsApi/Models/ExternalAuthModels.cs b/api/QuestionsApi/Models/ExternalAuthModels.cs new file mode 100644 index 0000000..9edba65 --- /dev/null +++ b/api/QuestionsApi/Models/ExternalAuthModels.cs @@ -0,0 +1,63 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public static class ExternalAuthProviders +{ + public const string Steam = "steam"; + public const string Google = "google"; + + public static bool Supported(string provider) => provider is Steam or Google; +} + +public sealed class ExternalIdentity +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ObjectId.GenerateNewId().ToString(); + public string UserId { get; set; } = ""; + public string Provider { get; set; } = ""; + public string Subject { get; set; } = ""; + public string? Email { get; set; } + public bool EmailVerified { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastUsedUtc { get; set; } +} + +public sealed class ExternalAuthTransaction +{ + [BsonId] public string Id { get; set; } = ""; + public string Provider { get; set; } = ""; + public string Purpose { get; set; } = "login"; + public string Status { get; set; } = "pending"; + public string? Subject { get; set; } + public string? ProviderEmail { get; set; } + public bool EmailVerified { get; set; } + public string? UserId { get; set; } + public string? Nonce { get; set; } + public string? CodeVerifier { get; set; } + public string? Error { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime ExpiresUtc { get; set; } +} + +public sealed class ExternalAuthStartRequest +{ + public string Purpose { get; set; } = "login"; +} + +public sealed class CompleteExternalAuthRequest +{ + public string TransactionId { get; set; } = ""; + public string Action { get; set; } = ""; + public string Username { get; set; } = ""; + public string? Email { get; set; } + public string Password { get; set; } = ""; + public bool AcceptLegal { get; set; } +} + +public sealed record ExternalProviderClaims( + string Subject, + string? Email, + bool EmailVerified); diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index a584fbb..3b472ea 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -49,6 +49,10 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -78,6 +82,9 @@ builder.Services.AddRateLimiter(options => options.AddPolicy("steam-start", context => Fixed(context, 10, TimeSpan.FromMinutes(1))); options.AddPolicy("steam-poll", context => Fixed(context, 120, TimeSpan.FromMinutes(1))); options.AddPolicy("steam-complete", context => Fixed(context, 20, TimeSpan.FromMinutes(1))); + options.AddPolicy("external-start", context => Fixed(context, 10, TimeSpan.FromMinutes(1))); + options.AddPolicy("external-poll", context => Fixed(context, 120, TimeSpan.FromMinutes(1))); + options.AddPolicy("external-complete", context => Fixed(context, 20, TimeSpan.FromMinutes(1))); options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; }); diff --git a/api/QuestionsApi/Services/AuthDataInitializer.cs b/api/QuestionsApi/Services/AuthDataInitializer.cs index 8a536c6..0980e84 100644 --- a/api/QuestionsApi/Services/AuthDataInitializer.cs +++ b/api/QuestionsApi/Services/AuthDataInitializer.cs @@ -8,12 +8,17 @@ public sealed class AuthDataInitializer( AuthUserStore users, TokenBlacklistStore blacklist, SteamAuthTransactionStore steamTransactions, + ExternalIdentityStore externalIdentities, + ExternalAuthTransactionStore externalTransactions, PasswordResetStore passwordResets, ILogger logger) { public async Task InitializeAsync() { await BackfillNormalizedEmailsAsync(); + await externalIdentities.BackfillSteamAsync(logger); + await externalIdentities.EnsureIndexesAsync(); + await externalTransactions.EnsureIndexesAsync(); await users.Collection.Indexes.CreateManyAsync([ new CreateIndexModel( diff --git a/api/QuestionsApi/Services/AuthUserStore.cs b/api/QuestionsApi/Services/AuthUserStore.cs index 48fea74..0b50580 100644 --- a/api/QuestionsApi/Services/AuthUserStore.cs +++ b/api/QuestionsApi/Services/AuthUserStore.cs @@ -70,6 +70,15 @@ public sealed class AuthUserStore(MongoDatabaseProvider mongo) return (await _users.UpdateOneAsync(filter, update)).MatchedCount == 1; } + public async Task TrySetSteamIdAsync(string userId, string steamId) + { + var filter = Builders.Filter.Eq(user => user.Id, userId) & + (Builders.Filter.Eq(user => user.SteamId, null) | + Builders.Filter.Eq(user => user.SteamId, steamId)); + return (await _users.UpdateOneAsync(filter, + Builders.Update.Set(user => user.SteamId, steamId))).MatchedCount == 1; + } + public async Task ResetPasswordAsync(string userId, string passwordHash) { var update = Builders.Update diff --git a/api/QuestionsApi/Services/ExternalAuthProviders.cs b/api/QuestionsApi/Services/ExternalAuthProviders.cs new file mode 100644 index 0000000..b3fa2fb --- /dev/null +++ b/api/QuestionsApi/Services/ExternalAuthProviders.cs @@ -0,0 +1,173 @@ +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using QuestionsApi.Models; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace QuestionsApi.Services; + +public interface IExternalAuthProvider +{ + string Id { get; } + string DisplayName { get; } + bool IsConfigured { get; } + string CallbackUrl { get; } + string BuildAuthorizationUrl(ExternalAuthTransaction transaction); + Task VerifyCallbackAsync( + IQueryCollection query, + ExternalAuthTransaction transaction, + CancellationToken cancellationToken); +} + +public sealed class SteamExternalAuthProvider( + SteamOpenIdService openId, + IConfiguration configuration) : IExternalAuthProvider +{ + public string Id => ExternalAuthProviders.Steam; + public string DisplayName => "Steam"; + public bool IsConfigured => true; + public string CallbackUrl => configuration["ExternalAuth:SteamReturnUrl"] ?? + $"{Realm()}/api/Auth/external/steam/callback"; + + public string BuildAuthorizationUrl(ExternalAuthTransaction transaction) => + SteamOpenIdService.BuildAuthenticationUrl(Realm(), CallbackUrlWithTransaction(transaction.Id)); + + public async Task VerifyCallbackAsync( + IQueryCollection query, + ExternalAuthTransaction transaction, + CancellationToken cancellationToken) + { + var subject = await openId.VerifyCallbackAsync( + query, CallbackUrlWithTransaction(transaction.Id), cancellationToken); + return subject is null ? null : new ExternalProviderClaims(subject, null, false); + } + + private string Realm() => + (configuration["Steam:Realm"] ?? "https://s3q.ranaze.com").TrimEnd('/'); + + private string CallbackUrlWithTransaction(string transactionId) => + $"{CallbackUrl}?transactionId={Uri.EscapeDataString(transactionId)}"; +} + +public sealed class GoogleExternalAuthProvider : IExternalAuthProvider +{ + private const string AuthorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"; + private const string TokenEndpoint = "https://oauth2.googleapis.com/token"; + private const string DiscoveryEndpoint = "https://accounts.google.com/.well-known/openid-configuration"; + private readonly IHttpClientFactory _httpClients; + private readonly IConfiguration _configuration; + private readonly IConfigurationManager _discovery; + + public GoogleExternalAuthProvider(IHttpClientFactory httpClients, IConfiguration configuration) + { + _httpClients = httpClients; + _configuration = configuration; + _discovery = new ConfigurationManager( + DiscoveryEndpoint, new OpenIdConnectConfigurationRetriever()); + } + + public string Id => ExternalAuthProviders.Google; + public string DisplayName => "Google"; + public string CallbackUrl => _configuration["Google:ReturnUrl"] ?? ""; + public bool IsConfigured => + !string.IsNullOrWhiteSpace(ClientId) && + !string.IsNullOrWhiteSpace(ClientSecret) && + Uri.TryCreate(CallbackUrl, UriKind.Absolute, out var callback) && callback.Scheme == Uri.UriSchemeHttps; + + private string ClientId => _configuration["Google:ClientId"] ?? ""; + private string ClientSecret => _configuration["Google:ClientSecret"] ?? ""; + + public string BuildAuthorizationUrl(ExternalAuthTransaction transaction) + { + var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(transaction.CodeVerifier ?? ""))); + return AuthorizationEndpoint + "?" + Query(new Dictionary + { + ["client_id"] = ClientId, + ["redirect_uri"] = CallbackUrl, + ["response_type"] = "code", + ["scope"] = "openid email profile", + ["state"] = transaction.Id, + ["nonce"] = transaction.Nonce ?? "", + ["code_challenge"] = challenge, + ["code_challenge_method"] = "S256", + ["prompt"] = "select_account" + }); + } + + public async Task VerifyCallbackAsync( + IQueryCollection query, + ExternalAuthTransaction transaction, + CancellationToken cancellationToken) + { + if (!IsConfigured || !string.IsNullOrWhiteSpace(query["error"]) || + !CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(query["state"].ToString()), + Encoding.UTF8.GetBytes(transaction.Id))) + return null; + var code = query["code"].ToString(); + if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(transaction.CodeVerifier)) return null; + + var client = _httpClients.CreateClient(nameof(GoogleExternalAuthProvider)); + client.Timeout = TimeSpan.FromSeconds(15); + using var response = await client.PostAsync(TokenEndpoint, new FormUrlEncodedContent(new Dictionary + { + ["client_id"] = ClientId, + ["client_secret"] = ClientSecret, + ["code"] = code, + ["code_verifier"] = transaction.CodeVerifier, + ["grant_type"] = "authorization_code", + ["redirect_uri"] = CallbackUrl + }), cancellationToken); + if (!response.IsSuccessStatusCode) return null; + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + if (!payload.RootElement.TryGetProperty("id_token", out var idTokenElement)) return null; + var idToken = idTokenElement.GetString(); + if (string.IsNullOrWhiteSpace(idToken)) return null; + + var oidc = await _discovery.GetConfigurationAsync(cancellationToken); + var handler = new JwtSecurityTokenHandler { MapInboundClaims = false }; + ClaimsPrincipal principal; + try + { + principal = handler.ValidateToken(idToken, new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuers = ["https://accounts.google.com", "accounts.google.com"], + ValidateAudience = true, + ValidAudience = ClientId, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKeys = oidc.SigningKeys, + ClockSkew = TimeSpan.FromMinutes(2) + }, out _); + } + catch (SecurityTokenException) + { + return null; + } + + var subject = principal.FindFirstValue("sub"); + var nonce = principal.FindFirstValue("nonce"); + var email = principal.FindFirstValue("email"); + var verified = string.Equals(principal.FindFirstValue("email_verified"), "true", + StringComparison.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(nonce) || + !CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(nonce), Encoding.UTF8.GetBytes(transaction.Nonce ?? ""))) + return null; + return new ExternalProviderClaims(subject, email, verified); + } + + internal static string NewVerifier() => Base64Url(RandomNumberGenerator.GetBytes(48)); + internal static string NewNonce() => Base64Url(RandomNumberGenerator.GetBytes(32)); + + private static string Query(Dictionary values) => string.Join("&", values.Select(pair => + $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")); + + private static string Base64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} diff --git a/api/QuestionsApi/Services/ExternalIdentityStore.cs b/api/QuestionsApi/Services/ExternalIdentityStore.cs new file mode 100644 index 0000000..ba0e48b --- /dev/null +++ b/api/QuestionsApi/Services/ExternalIdentityStore.cs @@ -0,0 +1,164 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class ExternalIdentityStore(MongoDatabaseProvider mongo, AuthUserStore users) +{ + private readonly IMongoCollection _identities = + mongo.Database.GetCollection("ExternalIdentities"); + + public async Task GetAsync(string provider, string subject) => + await _identities.Find(value => value.Provider == provider && value.Subject == subject) + .FirstOrDefaultAsync(); + + public async Task GetForUserAsync(string userId, string provider) => + await _identities.Find(value => value.UserId == userId && value.Provider == provider) + .FirstOrDefaultAsync(); + + public async Task> ProvidersForUserAsync(string userId) => + await _identities.Find(value => value.UserId == userId) + .Project(value => value.Provider).ToListAsync(); + + public async Task TouchAsync(string id) => + await _identities.UpdateOneAsync(value => value.Id == id, + Builders.Update.Set(value => value.LastUsedUtc, DateTime.UtcNow)); + + public async Task TryLinkAsync( + string userId, + string provider, + ExternalProviderClaims claims) + { + var bySubject = await GetAsync(provider, claims.Subject); + if (bySubject is not null) return bySubject.UserId == userId; + var byUser = await GetForUserAsync(userId, provider); + if (byUser is not null) return byUser.Subject == claims.Subject; + + var now = DateTime.UtcNow; + try + { + await _identities.InsertOneAsync(new ExternalIdentity + { + UserId = userId, + Provider = provider, + Subject = claims.Subject, + Email = claims.Email, + EmailVerified = claims.EmailVerified, + CreatedUtc = now, + LastUsedUtc = now + }); + if (provider == ExternalAuthProviders.Steam && + !await users.TrySetSteamIdAsync(userId, claims.Subject)) + { + await _identities.DeleteOneAsync(value => + value.UserId == userId && value.Provider == provider && value.Subject == claims.Subject); + return false; + } + return true; + } + catch (MongoWriteException exception) when ( + exception.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + return false; + } + } + + public async Task BackfillSteamAsync(ILogger logger) + { + var candidates = await users.Collection.Find(user => user.SteamId != null && user.SteamId != "") + .ToListAsync(); + var inserted = 0; + foreach (var user in candidates) + { + var filter = Builders.Filter.Eq(value => value.Provider, ExternalAuthProviders.Steam) & + Builders.Filter.Eq(value => value.Subject, user.SteamId!); + var update = Builders.Update + .SetOnInsert(value => value.UserId, user.Id) + .SetOnInsert(value => value.Provider, ExternalAuthProviders.Steam) + .SetOnInsert(value => value.Subject, user.SteamId!) + .SetOnInsert(value => value.CreatedUtc, user.CreatedUtc ?? DateTime.UtcNow) + .SetOnInsert(value => value.LastUsedUtc, DateTime.UtcNow); + var result = await _identities.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = true }); + if (result.UpsertedId is not null) inserted++; + var identity = await _identities.Find(filter).FirstAsync(); + if (identity.UserId != user.Id) + throw new InvalidOperationException( + $"Steam identity {user.SteamId} is associated with conflicting users."); + } + logger.LogInformation("External identity migration found {Candidates} Steam users and inserted {Inserted}", + candidates.Count, inserted); + } + + public async Task EnsureIndexesAsync() + { + await _identities.Indexes.CreateManyAsync([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.Provider) + .Ascending(value => value.Subject), + new CreateIndexOptions { Unique = true, Name = "external_identity_provider_subject" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId) + .Ascending(value => value.Provider), + new CreateIndexOptions { Unique = true, Name = "external_identity_user_provider" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId), + new CreateIndexOptions { Name = "external_identity_user" }) + ]); + } + + internal IMongoCollection Collection => _identities; +} + +public sealed class ExternalAuthTransactionStore(MongoDatabaseProvider mongo) +{ + private readonly IMongoCollection _transactions = + mongo.Database.GetCollection("ExternalAuthTransactions"); + + public Task CreateAsync(ExternalAuthTransaction transaction) => _transactions.InsertOneAsync(transaction); + + public async Task GetAsync(string id) => + await _transactions.Find(value => value.Id == id).FirstOrDefaultAsync(); + + public async Task MarkVerifiedAsync( + string id, + ExternalProviderClaims claims, + string status, + string? userId, + string? error = null) + { + var filter = Builders.Filter.Eq(value => value.Id, id) & + Builders.Filter.Eq(value => value.Status, "pending") & + Builders.Filter.Gt(value => value.ExpiresUtc, DateTime.UtcNow); + var update = Builders.Update + .Set(value => value.Subject, claims.Subject) + .Set(value => value.ProviderEmail, claims.Email) + .Set(value => value.EmailVerified, claims.EmailVerified) + .Set(value => value.Status, status) + .Set(value => value.UserId, userId) + .Set(value => value.Error, error); + return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1; + } + + public async Task MarkCompleteAsync(string id, string subject, string userId) + { + var filter = Builders.Filter.Eq(value => value.Id, id) & + Builders.Filter.Eq(value => value.Status, "requires_account") & + Builders.Filter.Eq(value => value.Subject, subject) & + Builders.Filter.Gt(value => value.ExpiresUtc, DateTime.UtcNow); + var update = Builders.Update + .Set(value => value.Status, "complete") + .Set(value => value.UserId, userId); + return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1; + } + + public Task DeleteForUserAsync(string userId) => + _transactions.DeleteManyAsync(value => value.UserId == userId); + + public Task EnsureIndexesAsync() => _transactions.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.ExpiresUtc), + new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "external_auth_expiry" })); + + internal IMongoCollection Collection => _transactions; +} diff --git a/api/QuestionsApi/Services/UserProfileStore.cs b/api/QuestionsApi/Services/UserProfileStore.cs index 1b79a19..e798da5 100644 --- a/api/QuestionsApi/Services/UserProfileStore.cs +++ b/api/QuestionsApi/Services/UserProfileStore.cs @@ -15,7 +15,8 @@ public sealed class UserProfileStore "QuestionAttempts", "UserRatings", "QuestionBookmarks", "UserNotepads", "ReviewQueueItems", "QuestionReviewAttempts", "ReviewQueueBackfillStates", "CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations", - "Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles" + "Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles", + "ExternalIdentities", "ExternalAuthTransactions" ]; public UserProfileStore(MongoDatabaseProvider mongo) diff --git a/docs/EXTERNAL_AUTH.md b/docs/EXTERNAL_AUTH.md new file mode 100644 index 0000000..7ac3bd3 --- /dev/null +++ b/docs/EXTERNAL_AUTH.md @@ -0,0 +1,34 @@ +# External Authentication + +RanaZe supports provider-neutral external identities. Users can sign in with a configured provider or connect it from Profile. Every account remains password-backed, and an external identity is never linked automatically by matching email addresses. + +## Google setup + +Create separate Google OAuth 2.0 Web application clients for UAT and production. Configure these exact authorized redirect URIs: + +- UAT: `https://us3q.ranaze.com/api/Auth/external/google/callback` +- Production: `https://s3q.ranaze.com/api/Auth/external/google/callback` + +Add the credentials as Gitea repository secrets: + +- `UAT_GOOGLE_CLIENT_ID` +- `UAT_GOOGLE_CLIENT_SECRET` +- `PRD_GOOGLE_CLIENT_ID` +- `PRD_GOOGLE_CLIENT_SECRET` + +The deployment scripts treat Google as optional. If either credential is absent, Google is omitted from the API provider list and the client does not display its button. + +## Account-linking rules + +- A provider subject can belong to only one RanaZe user. +- A RanaZe user can connect at most one identity per provider. +- A verified Google email that already exists requires the user to choose **Link an existing RanaZe account** and enter that account's password. +- Connected providers are intentionally not removable in the current UI to prevent accidental lockout and identity takeover edge cases. +- Legacy `Users.SteamId` values are backfilled into `ExternalIdentities`; new Steam links are dual-written during the compatibility period. + +## Collections + +- `ExternalIdentities` stores durable provider-to-user mappings. +- `ExternalAuthTransactions` stores short-lived login/link state and has a TTL index. + +Deleting a user purges both collections for that user. diff --git a/k8s/base/api-deployment.yaml b/k8s/base/api-deployment.yaml index da6d278..405b377 100644 --- a/k8s/base/api-deployment.yaml +++ b/k8s/base/api-deployment.yaml @@ -56,6 +56,16 @@ spec: secretKeyRef: name: step3-runtime-secrets key: stripe-webhook-secret + - name: Google__ClientId + valueFrom: + secretKeyRef: + name: step3-runtime-secrets + key: google-client-id + - name: Google__ClientSecret + valueFrom: + secretKeyRef: + name: step3-runtime-secrets + key: google-client-secret resources: requests: cpu: 250m @@ -79,4 +89,3 @@ spec: periodSeconds: 20 timeoutSeconds: 3 failureThreshold: 3 - diff --git a/k8s/overlays/prd/kustomization.yaml b/k8s/overlays/prd/kustomization.yaml index ee7fe30..4610045 100644 --- a/k8s/overlays/prd/kustomization.yaml +++ b/k8s/overlays/prd/kustomization.yaml @@ -20,6 +20,8 @@ configMapGenerator: - PasswordReset__Enabled=false - Steam__Realm=https://s3q.ranaze.com - Steam__ReturnUrl=https://s3q.ranaze.com/api/Auth/steam/callback + - ExternalAuth__SteamReturnUrl=https://s3q.ranaze.com/api/Auth/external/steam/callback + - Google__ReturnUrl=https://s3q.ranaze.com/api/Auth/external/google/callback - Legal__TermsVersion=2026-07-20 - Legal__PrivacyVersion=2026-07-20 - Legal__EffectiveUtc=2026-07-20T00:00:00Z diff --git a/k8s/overlays/uat/kustomization.yaml b/k8s/overlays/uat/kustomization.yaml index 1161c56..b4d0cd2 100644 --- a/k8s/overlays/uat/kustomization.yaml +++ b/k8s/overlays/uat/kustomization.yaml @@ -17,6 +17,8 @@ configMapGenerator: - PasswordReset__Enabled=false - Steam__Realm=https://us3q.ranaze.com - Steam__ReturnUrl=https://us3q.ranaze.com/api/Auth/steam/callback + - ExternalAuth__SteamReturnUrl=https://us3q.ranaze.com/api/Auth/external/steam/callback + - Google__ReturnUrl=https://us3q.ranaze.com/api/Auth/external/google/callback - Legal__TermsVersion=2026-07-20 - Legal__PrivacyVersion=2026-07-20 - Legal__EffectiveUtc=2026-07-20T00:00:00Z @@ -26,4 +28,3 @@ configMapGenerator: - Stripe__CancelUrl=https://ustep3.ranaze.com/donation-canceled.html generatorOptions: disableNameSuffixHash: true - diff --git a/project.godot b/project.godot index 49d1903..1ae990d 100644 --- a/project.godot +++ b/project.godot @@ -59,12 +59,12 @@ textures/vram_compression/import_etc2_astc=true [step3] deployment_environment="development" -questions_api_url="https://s3q.ranaze.com/api/questions/search" -question_reports_api_url="https://s3q.ranaze.com/api/question-reports" -app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback" -notes_api_url="https://s3q.ranaze.com/api/notes" -dashboard_api_url="https://s3q.ranaze.com/api/dashboard" -profile_api_url="https://s3q.ranaze.com/api/profile" -donations_api_url="https://s3q.ranaze.com/api/donations" -review_queue_api_url="https://s3q.ranaze.com/api/review-queue" +questions_api_url="https://us3q.ranaze.com/api/questions/search" +question_reports_api_url="https://us3q.ranaze.com/api/question-reports" +app_feedback_api_url="https://us3q.ranaze.com/api/app-feedback" +notes_api_url="https://us3q.ranaze.com/api/notes" +dashboard_api_url="https://us3q.ranaze.com/api/dashboard" +profile_api_url="https://us3q.ranaze.com/api/profile" +donations_api_url="https://us3q.ranaze.com/api/donations" +review_queue_api_url="https://us3q.ranaze.com/api/review-queue" build_revision="development" diff --git a/scripts/autoload/account_profile_service.gd b/scripts/autoload/account_profile_service.gd index b847264..4c9ff0c 100644 --- a/scripts/autoload/account_profile_service.gd +++ b/scripts/autoload/account_profile_service.gd @@ -6,20 +6,29 @@ signal preferences_saved signal password_changed signal account_deleted signal archive_saved(path: String) +signal external_link_started(provider: String) +signal external_linked(provider: String) const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const AUTH_URL := "https://s3q.ranaze.com/api/Auth" -const PROFILE_URL := "https://s3q.ranaze.com/api/profile" +const AUTH_URL := "https://us3q.ranaze.com/api/Auth" +const PROFILE_URL := "https://us3q.ranaze.com/api/profile" var account: Dictionary = {} var preferences: Dictionary = _default_preferences() var auth_request: HTTPRequest var profile_request: HTTPRequest +var external_request: HTTPRequest +var external_poll_timer: Timer var auth_operation := "" var profile_operation := "" var auth_token := "" var profile_token := "" var preferences_pending := false +var external_operation := "" +var external_provider := "" +var external_provider_name := "" +var external_transaction_id := "" +var external_poll_deadline_msec := 0 func _ready() -> void: @@ -31,6 +40,14 @@ func _ready() -> void: profile_request.timeout = 30.0 profile_request.request_completed.connect(_on_profile_completed) add_child(profile_request) + external_request = HTTPRequest.new() + external_request.timeout = 25.0 + external_request.request_completed.connect(_on_external_completed) + add_child(external_request) + external_poll_timer = Timer.new() + external_poll_timer.wait_time = 2.0 + external_poll_timer.timeout.connect(_poll_external_link) + add_child(external_poll_timer) AuthState.auth_changed.connect(_on_auth_changed) @@ -83,6 +100,32 @@ func delete_account(password: String, confirmation: String) -> void: }) +func connect_provider(provider: String, provider_name: String) -> void: + if not AuthState.can_send_authenticated_request(): + request_failed.emit("Sign in again before connecting an account.") + return + if external_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + request_failed.emit("Another connection request is still in progress.") + return + external_provider = provider + external_provider_name = provider_name + var storage_key := _external_storage_key() + if OS.has_feature("web"): + var popup_result: Variant = JavaScriptBridge.eval( + "window.localStorage.removeItem(%s); var bridge = new URL('/external-redirect.html?key=' + encodeURIComponent(%s) + '&provider=' + encodeURIComponent(%s), window.location.origin).href; window.ranazeProfileExternalWindow = window.open(bridge, 'ranaze_profile_external', 'popup=yes,width=560,height=720,resizable=yes,scrollbars=yes'); window.ranazeProfileExternalWindow !== null;" % [JSON.stringify(storage_key), JSON.stringify(storage_key), JSON.stringify(provider_name)], true) + if not bool(popup_result): + request_failed.emit("Allow popups for this site, then try again.") + _reset_external_link() + return + external_operation = "start" + var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token, "Content-Type: application/json"]) + var error := external_request.request("%s/external/%s/start" % [_auth_base(), provider], headers, + HTTPClient.METHOD_POST, JSON.stringify({"purpose": "link"})) + if error != OK: + request_failed.emit("The account provider could not be reached.") + _reset_external_link() + + func _request_auth(operation: String, method: HTTPClient.Method, path: String, payload: Variant = null) -> void: if auth_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: request_failed.emit("Another account request is still in progress.") @@ -162,6 +205,82 @@ func _on_profile_completed(result: int, response_code: int, _headers: PackedStri _save_archive(JSON.stringify(archive, " ")) +func _on_external_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + var operation := external_operation + external_operation = "" + if response_code == 401: + AuthState.handle_unauthorized(AuthState.access_token) + _reset_external_link() + return + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + request_failed.emit(_error_message(body, "The external account request failed.")) + _reset_external_link() + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + request_failed.emit("The external account service returned invalid data.") + _reset_external_link() + return + var data: Dictionary = parsed + if operation == "start": + external_transaction_id = str(data.get("transactionId", "")) + var auth_url := str(data.get("authUrl", "")) + if external_transaction_id.is_empty() or auth_url.is_empty(): + request_failed.emit("The provider returned an invalid response.") + _reset_external_link() + return + if OS.has_feature("web"): + JavaScriptBridge.eval("var url=%s; window.localStorage.setItem(%s,url); if(window.ranazeProfileExternalWindow&&!window.ranazeProfileExternalWindow.closed){window.ranazeProfileExternalWindow.location.replace(url);window.ranazeProfileExternalWindow.focus();}" % [JSON.stringify(auth_url), JSON.stringify(_external_storage_key())], true) + else: + OS.shell_open(auth_url) + external_poll_deadline_msec = Time.get_ticks_msec() + 300000 + external_poll_timer.start() + external_link_started.emit(external_provider) + elif operation == "status": + match str(data.get("status", "pending")): + "linked": + var linked_provider := external_provider + _reset_external_link() + external_linked.emit(linked_provider) + refresh() + "failed": + request_failed.emit(str(data.get("message", "The provider could not connect this account."))) + _reset_external_link() + + +func _poll_external_link() -> void: + if external_transaction_id.is_empty() or external_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + return + if Time.get_ticks_msec() >= external_poll_deadline_msec: + request_failed.emit("The account connection timed out.") + _reset_external_link() + return + external_operation = "status" + var error := external_request.request("%s/external/%s/status/%s" % [_auth_base(), external_provider, external_transaction_id]) + if error != OK: + external_operation = "" + + +func _reset_external_link() -> void: + if external_poll_timer != null: + external_poll_timer.stop() + if OS.has_feature("web") and not external_provider.is_empty(): + JavaScriptBridge.eval("window.localStorage.removeItem(%s);if(window.ranazeProfileExternalWindow&&!window.ranazeProfileExternalWindow.closed){window.ranazeProfileExternalWindow.close();}window.ranazeProfileExternalWindow=null;" % JSON.stringify(_external_storage_key()), true) + external_operation = "" + external_provider = "" + external_provider_name = "" + external_transaction_id = "" + external_poll_deadline_msec = 0 + + +func _external_storage_key() -> String: + return "ranaze_profile_external_auth_url_%s" % external_provider + + +func _auth_base() -> String: + return EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/auth_api_base_url", AUTH_URL)), "/api/Auth").rstrip("/") + + func _save_archive(content: String) -> void: var filename := "ranaze-account-export-%s.json" % Time.get_date_string_from_system() if OS.has_feature("web"): diff --git a/scripts/autoload/auth_state.gd b/scripts/autoload/auth_state.gd index d52a0b1..0e99b6f 100644 --- a/scripts/autoload/auth_state.gd +++ b/scripts/autoload/auth_state.gd @@ -9,7 +9,7 @@ signal session_refresh_failed(message: String, terminal: bool) signal session_expired(reason: String) const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const DEFAULT_AUTH_BASE_URL := "https://s3q.ranaze.com/api/Auth" +const DEFAULT_AUTH_BASE_URL := "https://us3q.ranaze.com/api/Auth" const WARNING_SECONDS := 120 const REFRESH_TIMEOUT_SECONDS := 20.0 diff --git a/scripts/autoload/bookmark_service.gd b/scripts/autoload/bookmark_service.gd index ba8771a..853299e 100644 --- a/scripts/autoload/bookmark_service.gd +++ b/scripts/autoload/bookmark_service.gd @@ -1,7 +1,7 @@ extends Node const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const DEFAULT_API_URL := "https://s3q.ranaze.com/api/bookmarks" +const DEFAULT_API_URL := "https://us3q.ranaze.com/api/bookmarks" const REQUEST_TIMEOUT_SECONDS := 30.0 const RETRY_DELAY_SECONDS := 5.0 const QUEUE_PATH := "user://pending_question_bookmarks.json" diff --git a/scripts/autoload/ccs_service.gd b/scripts/autoload/ccs_service.gd index e3912d8..ac446bf 100644 --- a/scripts/autoload/ccs_service.gd +++ b/scripts/autoload/ccs_service.gd @@ -1,7 +1,7 @@ extends Node const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const DEFAULT_API_URL := "https://s3q.ranaze.com/api/ccs" +const DEFAULT_API_URL := "https://us3q.ranaze.com/api/ccs" signal cases_changed signal attempt_changed diff --git a/scripts/autoload/dashboard_service.gd b/scripts/autoload/dashboard_service.gd index 1d6152e..9722380 100644 --- a/scripts/autoload/dashboard_service.gd +++ b/scripts/autoload/dashboard_service.gd @@ -4,7 +4,7 @@ signal dashboard_changed signal dashboard_failed(message: String) const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const DEFAULT_URL := "https://s3q.ranaze.com/api/dashboard" +const DEFAULT_URL := "https://us3q.ranaze.com/api/dashboard" var data: Dictionary = {} var loading := false diff --git a/scripts/autoload/donation_service.gd b/scripts/autoload/donation_service.gd index c7c3fbd..a34f9a0 100644 --- a/scripts/autoload/donation_service.gd +++ b/scripts/autoload/donation_service.gd @@ -1,7 +1,7 @@ extends Node const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const DEFAULT_URL := "https://s3q.ranaze.com/api/donations" +const DEFAULT_URL := "https://us3q.ranaze.com/api/donations" const REQUEST_TIMEOUT_SECONDS := 30.0 const POLL_INTERVAL_SECONDS := 3.0 const POLL_LIMIT := 200 diff --git a/scripts/autoload/exam_simulation_service.gd b/scripts/autoload/exam_simulation_service.gd index 1186bb4..a114119 100644 --- a/scripts/autoload/exam_simulation_service.gd +++ b/scripts/autoload/exam_simulation_service.gd @@ -1,7 +1,7 @@ extends Node const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const DEFAULT_API_URL := "https://s3q.ranaze.com/api/exam-simulations" +const DEFAULT_API_URL := "https://us3q.ranaze.com/api/exam-simulations" signal readiness_changed signal state_changed @@ -10,7 +10,7 @@ signal outcome_changed signal history_changed var api_url := DEFAULT_API_URL -var outcome_api_url := "https://s3q.ranaze.com/api/step3-outcomes" +var outcome_api_url := "https://us3q.ranaze.com/api/step3-outcomes" var request: HTTPRequest var readiness := {} var simulation := {} @@ -31,7 +31,7 @@ func _ready() -> void: request = HTTPRequest.new() request.request_completed.connect(_on_request_completed) add_child(request) - outcome_api_url = EndpointConfig.resolve("https://s3q.ranaze.com/api/step3-outcomes", "/api/step3-outcomes") + outcome_api_url = EndpointConfig.resolve("https://us3q.ranaze.com/api/step3-outcomes", "/api/step3-outcomes") func load_readiness() -> void: diff --git a/scripts/autoload/feedback_service.gd b/scripts/autoload/feedback_service.gd index 216e4d5..47d17a1 100644 --- a/scripts/autoload/feedback_service.gd +++ b/scripts/autoload/feedback_service.gd @@ -1,7 +1,7 @@ extends Node const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const DEFAULT_API_URL := "https://s3q.ranaze.com/api/app-feedback" +const DEFAULT_API_URL := "https://us3q.ranaze.com/api/app-feedback" const REQUEST_TIMEOUT_SECONDS := 30.0 signal feedback_submitted(receipt_id: String) diff --git a/scripts/autoload/legal_service.gd b/scripts/autoload/legal_service.gd index b0e015f..47e4050 100644 --- a/scripts/autoload/legal_service.gd +++ b/scripts/autoload/legal_service.gd @@ -4,7 +4,7 @@ signal acceptance_succeeded signal acceptance_failed(message: String) const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const DEFAULT_AUTH_BASE_URL := "https://s3q.ranaze.com/api/Auth" +const DEFAULT_AUTH_BASE_URL := "https://us3q.ranaze.com/api/Auth" const DOCUMENT_PATH := "res://legal/legal_documents.json" var documents: Dictionary = {} diff --git a/scripts/autoload/notes_service.gd b/scripts/autoload/notes_service.gd index d7735c1..56df73a 100644 --- a/scripts/autoload/notes_service.gd +++ b/scripts/autoload/notes_service.gd @@ -1,7 +1,7 @@ extends Node const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const DEFAULT_API_URL := "https://s3q.ranaze.com/api/notes" +const DEFAULT_API_URL := "https://us3q.ranaze.com/api/notes" const REQUEST_TIMEOUT_SECONDS := 30.0 const AUTOSAVE_DELAY_SECONDS := 1.5 const RETRY_DELAY_SECONDS := 5.0 diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index 0f7f1e4..9e4d369 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -1,11 +1,11 @@ extends Node const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const DEFAULT_QUESTIONS_API_URL := "https://s3q.ranaze.com/api/questions/search" -const DEFAULT_QUESTION_ATTEMPT_API_URL := "https://s3q.ranaze.com/api/questions/attempts/first" -const DEFAULT_QUESTION_API_BASE_URL := "https://s3q.ranaze.com/api/questions" -const DEFAULT_RATINGS_API_URL := "https://s3q.ranaze.com/api/ratings/me" -const DEFAULT_QUESTION_REPORTS_API_URL := "https://s3q.ranaze.com/api/question-reports" +const DEFAULT_QUESTIONS_API_URL := "https://us3q.ranaze.com/api/questions/search" +const DEFAULT_QUESTION_ATTEMPT_API_URL := "https://us3q.ranaze.com/api/questions/attempts/first" +const DEFAULT_QUESTION_API_BASE_URL := "https://us3q.ranaze.com/api/questions" +const DEFAULT_RATINGS_API_URL := "https://us3q.ranaze.com/api/ratings/me" +const DEFAULT_QUESTION_REPORTS_API_URL := "https://us3q.ranaze.com/api/question-reports" const RETRY_DELAYS := [2, 5, 10, 20, 30] const REQUEST_TIMEOUT_SECONDS := 30.0 const IMAGE_QUESTION_SEARCH_LIMIT := 200 diff --git a/scripts/autoload/review_queue_service.gd b/scripts/autoload/review_queue_service.gd index e3261f7..19cd90b 100644 --- a/scripts/autoload/review_queue_service.gd +++ b/scripts/autoload/review_queue_service.gd @@ -6,7 +6,7 @@ signal pass_completed(summary: Dictionary) signal attempt_synced(question_id: String, is_correct: bool, pending_count: int) const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const DEFAULT_API_URL := "https://s3q.ranaze.com/api/review-queue" +const DEFAULT_API_URL := "https://us3q.ranaze.com/api/review-queue" const OUTBOX_PATH := "user://pending_review_attempts.json" var api_url := DEFAULT_API_URL diff --git a/scripts/ui/login_screen.gd b/scripts/ui/login_screen.gd index 7e630f4..d72a3d9 100644 --- a/scripts/ui/login_screen.gd +++ b/scripts/ui/login_screen.gd @@ -2,8 +2,8 @@ extends Control const Brand := preload("res://scripts/ui/medmind_style.gd") const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const AUTH_BASE := "https://s3q.ranaze.com/api/Auth" -const STEAM_AUTH_STORAGE_KEY := "ranaze_steam_auth_url" +const AUTH_BASE := "https://us3q.ranaze.com/api/Auth" +const EXTERNAL_AUTH_STORAGE_PREFIX := "ranaze_external_auth_url_" var main: Control var auth_base_url := AUTH_BASE @@ -38,6 +38,7 @@ var steam_username_input: LineEdit var steam_email_input: LineEdit var steam_password_input: LineEdit var steam_legal_check: CheckBox +var external_buttons: Dictionary = {} var panel_gutter: MarginContainer var submit_buttons: Array[Button] = [] var fields: Array[LineEdit] = [] @@ -49,6 +50,9 @@ var steam_transaction_id := "" var steam_poll_deadline_msec := 0 var steam_poll_failures := 0 var steam_popup_opened := false +var external_provider_id := "" +var external_provider_name := "" +var external_verified_email := "" var automatic_login_pending := false var login_attempt_username := "" var compact_layout := false @@ -63,6 +67,7 @@ func _ready() -> void: _create_request("register", _on_register_completed) _create_request("reset_request", _on_reset_requested) _create_request("reset_confirm", _on_reset_confirmed) + _create_request("providers", _on_providers_loaded) _create_request("steam_start", _on_steam_started) _create_request("steam_status", _on_steam_status) _create_request("steam_complete", _on_steam_completed) @@ -76,6 +81,7 @@ func _ready() -> void: _update_responsive_layout.call_deferred() _set_mode("login") _apply_theme() + _load_external_providers() func on_show(payload: Variant = null) -> void: @@ -249,12 +255,15 @@ func _build_login_view() -> VBoxContainer: var sign_in := _add_primary_button(view, "Sign In", _on_login_pressed) password_input.text_submitted.connect(func(_text: String): _on_login_pressed()) view.add_child(_build_divider()) - var steam := Button.new() - steam.text = "Sign in with Steam" - steam.custom_minimum_size = Vector2(0, 68) - steam.pressed.connect(_start_steam) - view.add_child(steam) - submit_buttons.append(steam) + for provider in [["steam", "Steam"], ["google", "Google"]]: + var external := Button.new() + external.text = "Sign in with %s" % provider[1] + external.custom_minimum_size = Vector2(0, 68) + external.visible = false + external.pressed.connect(_start_external.bind(str(provider[0]), str(provider[1]))) + view.add_child(external) + external_buttons[provider[0]] = external + submit_buttons.append(external) var create := Button.new() create.text = "New to RanaZe? Create an account" create.flat = true @@ -298,7 +307,7 @@ func _build_reset_view() -> VBoxContainer: func _build_steam_view() -> VBoxContainer: var view := _new_form() var help := Label.new() - help.text = "Steam was verified. Create a RanaZe account or link an existing one." + help.text = "Your identity was verified. Create a RanaZe account or link an existing one." help.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART view.add_child(help) steam_action = OptionButton.new() @@ -312,7 +321,7 @@ func _build_steam_view() -> VBoxContainer: _add_show_password(view, [steam_password_input]) steam_legal_check = _add_legal_consent(view) _add_primary_button(view, "Continue", _complete_steam) - _add_back_button(view, "Cancel Steam sign in", "login") + _add_back_button(view, "Cancel external sign in", "login") return view @@ -461,7 +470,7 @@ func _set_mode(next_mode: String) -> void: match mode: "register": heading.text = "Create your account"; subheading.text = "Start studying with RanaZe" "reset": heading.text = "Reset your password"; subheading.text = "We will email you a verification code" - "steam": heading.text = "Finish Steam sign in"; subheading.text = "Connect Steam to RanaZe" + "steam": heading.text = "Finish %s sign in" % external_provider_name; subheading.text = "Connect %s to RanaZe" % external_provider_name _: heading.text = "Welcome back"; subheading.text = "Sign in to continue" _clear_message() _update_responsive_layout() @@ -538,7 +547,7 @@ func _apply_theme() -> void: typed.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) typed.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) for button in submit_buttons: - if button.text not in ["Sign in with Steam"]: + if not button.text.begins_with("Sign in with "): button.add_theme_stylebox_override("normal", Brand.gradient_button_box()) button.add_theme_color_override("font_color", Color.WHITE) var theme_normal := StyleBoxFlat.new() @@ -648,39 +657,66 @@ func _on_reset_confirmed(result: int, code: int, _headers: PackedStringArray, bo _show_success("Password updated. You can now sign in.") +func _load_external_providers() -> void: + var request: HTTPRequest = requests["providers"] + if request.get_http_client_status() == HTTPClient.STATUS_DISCONNECTED: + request.request("%s/external/providers" % auth_base_url) + + +func _on_providers_loaded(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300: + return + var data := _parse_dictionary(body) + var enabled: Dictionary = {} + for provider_value in data.get("providers", []): + if typeof(provider_value) == TYPE_DICTIONARY: + var provider: Dictionary = provider_value + enabled[str(provider.get("id", ""))] = true + for provider_id in external_buttons: + (external_buttons[provider_id] as Button).visible = enabled.has(provider_id) + + func _start_steam() -> void: + _start_external("steam", "Steam") + + +func _start_external(provider_id: String, provider_name: String) -> void: + external_provider_id = provider_id + external_provider_name = provider_name + external_verified_email = "" + var storage_key := _external_storage_key() if OS.has_feature("web"): var popup_result: Variant = JavaScriptBridge.eval( - "window.localStorage.removeItem('%s'); var ranazeSteamBridge = new URL('/external-redirect.html?key=%s&provider=Steam', window.location.origin).href; window.ranazeSteamWindow = window.open(ranazeSteamBridge, 'ranaze_steam', 'popup=yes,width=560,height=720,resizable=yes,scrollbars=yes'); if (window.ranazeSteamWindow) { window.ranazeSteamWindow.blur(); window.focus(); } window.ranazeSteamWindow !== null;" % [STEAM_AUTH_STORAGE_KEY, STEAM_AUTH_STORAGE_KEY], + "window.localStorage.removeItem(%s); var ranazeExternalBridge = new URL('/external-redirect.html?key=' + encodeURIComponent(%s) + '&provider=' + encodeURIComponent(%s), window.location.origin).href; window.ranazeExternalWindow = window.open(ranazeExternalBridge, 'ranaze_external', 'popup=yes,width=560,height=720,resizable=yes,scrollbars=yes'); if (window.ranazeExternalWindow) { window.ranazeExternalWindow.blur(); window.focus(); } window.ranazeExternalWindow !== null;" % [JSON.stringify(storage_key), JSON.stringify(storage_key), JSON.stringify(provider_name)], true ) steam_popup_opened = bool(popup_result) if not steam_popup_opened: - return _show_error("Steam sign in needs a popup. Allow popups for this site, then try again.") - _send_json("steam_start", "/steam/start", {}, "Opening Steam...") + return _show_error("%s sign in needs a popup. Allow popups for this site, then try again." % provider_name) + _send_json("steam_start", "/external/%s/start" % provider_id, {"purpose": "login"}, "Opening %s..." % provider_name) func _on_steam_started(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: _set_busy(false) if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300: _close_steam_popup() - return _show_error("Unable to start Steam sign in.") + return _show_error("Unable to start %s sign in." % external_provider_name) var data := _parse_dictionary(body) steam_transaction_id = str(data.get("transactionId", "")) var auth_url := str(data.get("authUrl", "")) if steam_transaction_id.is_empty() or auth_url.is_empty(): _close_steam_popup() - return _show_error("Steam returned an invalid sign-in response.") + return _show_error("%s returned an invalid sign-in response." % external_provider_name) if OS.has_feature("web"): JavaScriptBridge.eval( - "var ranazeSteamAuthUrl = %s; window.localStorage.setItem('%s', ranazeSteamAuthUrl); if (window.ranazeSteamWindow && !window.ranazeSteamWindow.closed) { try { window.ranazeSteamWindow.location.replace(ranazeSteamAuthUrl); } catch (_) {} window.ranazeSteamWindow.focus(); } else { var ranazeSteamPopup = window.open(ranazeSteamAuthUrl, 'ranaze_steam'); if (ranazeSteamPopup) { window.ranazeSteamWindow = ranazeSteamPopup; ranazeSteamPopup.focus(); } else { window.location.assign(ranazeSteamAuthUrl); } }" % [JSON.stringify(auth_url), STEAM_AUTH_STORAGE_KEY], + "var ranazeExternalAuthUrl = %s; window.localStorage.setItem(%s, ranazeExternalAuthUrl); if (window.ranazeExternalWindow && !window.ranazeExternalWindow.closed) { try { window.ranazeExternalWindow.location.replace(ranazeExternalAuthUrl); } catch (_) {} window.ranazeExternalWindow.focus(); } else { var ranazeExternalPopup = window.open(ranazeExternalAuthUrl, 'ranaze_external'); if (ranazeExternalPopup) { window.ranazeExternalWindow = ranazeExternalPopup; ranazeExternalPopup.focus(); } else { window.location.assign(ranazeExternalAuthUrl); } }" % [JSON.stringify(auth_url), JSON.stringify(_external_storage_key())], true ) else: OS.shell_open(auth_url) steam_poll_deadline_msec = Time.get_ticks_msec() + 300000 steam_poll_failures = 0 - _show_success("Complete sign in in the Steam window. Waiting for verification...") + _show_success("Complete sign in in the %s window. Waiting for verification..." % external_provider_name) poll_timer.start() @@ -690,28 +726,34 @@ func _poll_steam() -> void: if steam_poll_deadline_msec > 0 and Time.get_ticks_msec() >= steam_poll_deadline_msec: _reset_steam_flow() return _show_error("Steam sign in timed out. Try again when ready.") - var error: Error = status_request.request("%s/steam/status/%s" % [auth_base_url, steam_transaction_id]) + var error: Error = status_request.request("%s/external/%s/status/%s" % [auth_base_url, external_provider_id, steam_transaction_id]) if error != OK: steam_poll_failures += 1 - if steam_poll_failures >= 3: _show_error("Connection interrupted. Still waiting for Steam...") + if steam_poll_failures >= 3: _show_error("Connection interrupted. Still waiting for %s..." % external_provider_name) func _on_steam_status(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: if result != HTTPRequest.RESULT_SUCCESS or code >= 500 or code == 429: steam_poll_failures += 1 - if steam_poll_failures >= 3: _show_error("Connection interrupted. Still waiting for Steam...") + if steam_poll_failures >= 3: _show_error("Connection interrupted. Still waiting for %s..." % external_provider_name) return if code == 404: _reset_steam_flow() - return _show_error("Steam sign in expired. Please try again.") + return _show_error("%s sign in expired. Please try again." % external_provider_name) if code < 200 or code >= 300: _reset_steam_flow() - return _show_error(_response_message(body, "Steam sign in could not be completed.")) + return _show_error(_response_message(body, "%s sign in could not be completed." % external_provider_name)) steam_poll_failures = 0 var data := _parse_dictionary(body) match str(data.get("status", "pending")): - "pending": _show_success("Complete sign in in the Steam window. Waiting for verification...") - "requires_account": poll_timer.stop(); _close_steam_popup(); _set_mode("steam") + "pending": _show_success("Complete sign in in the %s window. Waiting for verification..." % external_provider_name) + "requires_account": + poll_timer.stop() + _close_steam_popup() + external_verified_email = str(data.get("email", "")) + steam_email_input.text = external_verified_email + steam_email_input.editable = external_verified_email.is_empty() + _set_mode("steam") "complete": poll_timer.stop(); _close_steam_popup(); _accept_session(body, str(data.get("username", ""))) @@ -730,7 +772,7 @@ func _complete_steam() -> void: return _show_error("Agree to the Terms of Service and acknowledge the Privacy Policy to create an account.") elif steam_username_input.text.strip_edges().is_empty() or steam_password_input.text.is_empty(): return _show_error("Enter your existing account credentials.") - _send_json("steam_complete", "/steam/complete", { + _send_json("steam_complete", "/external/%s/complete" % external_provider_id, { "transactionId": steam_transaction_id, "action": action, "username": steam_username_input.text.strip_edges(), "email": steam_email_input.text.strip_edges(), "password": steam_password_input.text, "acceptLegal": action == "create" @@ -740,7 +782,7 @@ func _complete_steam() -> void: func _on_steam_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: _set_busy(false) if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300: - return _show_error(_response_message(body, "Unable to connect this Steam account.")) + return _show_error(_response_message(body, "Unable to connect this %s account." % external_provider_name)) steam_poll_failures = 0 poll_timer.start() _poll_steam() @@ -748,18 +790,25 @@ func _on_steam_completed(result: int, code: int, _headers: PackedStringArray, bo func _reset_steam_flow() -> void: if poll_timer != null: poll_timer.stop() + _close_steam_popup() steam_transaction_id = "" steam_poll_deadline_msec = 0 steam_poll_failures = 0 - _close_steam_popup() + external_provider_id = "" + external_provider_name = "" + external_verified_email = "" func _close_steam_popup() -> void: if OS.has_feature("web") and steam_popup_opened: - JavaScriptBridge.eval("window.localStorage.removeItem('%s'); if (window.ranazeSteamWindow && !window.ranazeSteamWindow.closed) { window.ranazeSteamWindow.close(); } window.ranazeSteamWindow = null;" % STEAM_AUTH_STORAGE_KEY, true) + JavaScriptBridge.eval("window.localStorage.removeItem(%s); if (window.ranazeExternalWindow && !window.ranazeExternalWindow.closed) { window.ranazeExternalWindow.close(); } window.ranazeExternalWindow = null;" % JSON.stringify(_external_storage_key()), true) steam_popup_opened = false +func _external_storage_key() -> String: + return EXTERNAL_AUTH_STORAGE_PREFIX + external_provider_id + + func _accept_session(body: PackedByteArray, fallback_username: String) -> void: var response := _parse_dictionary(body) var token := str(response.get("accessToken", "")) diff --git a/scripts/ui/progress_profile.gd b/scripts/ui/progress_profile.gd index c95b5e9..971fb05 100644 --- a/scripts/ui/progress_profile.gd +++ b/scripts/ui/progress_profile.gd @@ -18,6 +18,7 @@ var outcome_date: LineEdit var outcome_consent: CheckBox var outcome_status: Label var outcome_delete: Button +var connected_accounts_list: VBoxContainer func _ready() -> void: @@ -26,6 +27,8 @@ func _ready() -> void: AccountProfileService.request_failed.connect(_show_error) AccountProfileService.preferences_saved.connect(func(): _show_saved("Study identity saved.")) AccountProfileService.archive_saved.connect(func(path: String): _show_saved("Archive saved: %s" % path)) + AccountProfileService.external_link_started.connect(func(provider: String): _show_saved("Complete %s connection in the opened window." % provider.capitalize())) + AccountProfileService.external_linked.connect(func(provider: String): _show_saved("%s account connected." % provider.capitalize())) DashboardService.dashboard_changed.connect(_render) DashboardService.dashboard_failed.connect(_show_error) ExamSimulationService.outcome_changed.connect(_render_outcome) @@ -82,6 +85,7 @@ func _build() -> void: right.add_theme_constant_override("separation", 14) columns.add_child(right) right.add_child(_outcome_card()) + right.add_child(_connected_accounts_card()) right.add_child(_account_actions_card()) _update_responsive() _render() @@ -228,6 +232,16 @@ func _account_actions_card() -> PanelContainer: return _card(body) +func _connected_accounts_card() -> PanelContainer: + var body := VBoxContainer.new() + body.add_theme_constant_override("separation", 9) + body.add_child(_section_header("Connected Accounts", "Use another trusted identity to sign in")) + connected_accounts_list = VBoxContainer.new() + connected_accounts_list.add_theme_constant_override("separation", 8) + body.add_child(connected_accounts_list) + return _card(body) + + func _render() -> void: if profile_name == null: return @@ -241,6 +255,7 @@ func _render() -> void: var role := str(account.get("role", AuthState.role)).capitalize() var created := _date_only(str(account.get("createdUtc", ""))) profile_meta.text = role if created.is_empty() else "%s | Member since %s" % [role, created] + _render_connected_accounts(account.get("connectedProviders", []), account.get("availableProviders", [])) (identity_values["exam"] as Label).text = _date_only(str(prefs.get("targetExamDate", "")), "Not scheduled") (identity_values["stage"] as Label).text = _friendly(str(prefs.get("preparationStage", "pre_dedicated"))) (identity_values["goal"] as Label).text = "%d hours / week" % int(prefs.get("weeklyGoalHours", 10)) @@ -256,6 +271,38 @@ func _render() -> void: _render_outcome() +func _render_connected_accounts(provider_values: Variant, available_values: Variant) -> void: + if connected_accounts_list == null: + return + _clear(connected_accounts_list) + var connected: Dictionary = {} + var available: Dictionary = {} + if typeof(provider_values) == TYPE_ARRAY: + for value in provider_values: + connected[str(value).to_lower()] = true + if typeof(available_values) == TYPE_ARRAY: + for value in available_values: + available[str(value).to_lower()] = true + for provider in [["steam", "Steam"], ["google", "Google"]]: + var provider_id := str(provider[0]) + var provider_name := str(provider[1]) + if not connected.has(provider_id) and not available.has(provider_id): + continue + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 10) + var copy := VBoxContainer.new() + copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL + copy.add_child(_label(provider_name, 14, Brand.text_color(_dark()), true)) + copy.add_child(_label("Connected" if connected.has(provider_id) else "Not connected", 12, + Brand.SUCCESS if connected.has(provider_id) else Brand.muted_color(_dark()))) + row.add_child(copy) + if not connected.has(provider_id): + var connect_button := _button("Connect", "link", false) + connect_button.pressed.connect(AccountProfileService.connect_provider.bind(provider_id, provider_name)) + row.add_child(connect_button) + connected_accounts_list.add_child(row) + + func _render_systems(systems: Array = DashboardService.data.get("systems", [])) -> void: if systems_list == null: return diff --git a/web/Dockerfile b/web/Dockerfile index 9138a0d..244e31e 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -5,7 +5,7 @@ ARG GODOT_RELEASE=${GODOT_VERSION}-stable ARG GODOT_TEMPLATE_DIR=${GODOT_VERSION}.stable ARG BUILD_REVISION=development ARG DEPLOYMENT_ENVIRONMENT=production -ARG PUBLIC_API_ORIGIN=https://s3q.ranaze.com +ARG PUBLIC_API_ORIGIN=https://us3q.ranaze.com RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -33,7 +33,7 @@ COPY . . RUN mkdir -p /build \ && sed -i "s|build_revision=\"development\"|build_revision=\"${BUILD_REVISION}\"|" project.godot \ && sed -i "s|deployment_environment=\"development\"|deployment_environment=\"${DEPLOYMENT_ENVIRONMENT}\"|" project.godot \ - && sed -i "s|https://s3q.ranaze.com|${PUBLIC_API_ORIGIN}|g" project.godot \ + && sed -i "s|https://us3q.ranaze.com|${PUBLIC_API_ORIGIN}|g" project.godot \ && sed -i "s|custom_template/release=\"\"|custom_template/release=\"/root/.local/share/godot/export_templates/${GODOT_TEMPLATE_DIR}/web_nothreads_release.zip\"|" export_presets.cfg \ && godot --headless --editor --path /src --quit \ && godot --verbose --headless --path /src --export-release Web /build/index.html \