Add provider-neutral Google authentication

This commit is contained in:
hz
2026-07-20 21:42:56 -05:00
parent 48e19f138e
commit 72fd1e3e0f
20 changed files with 1084 additions and 36 deletions
+2
View File
@@ -57,6 +57,8 @@ kubectl create secret generic step3-runtime-secrets \
--from-literal=purge-secret="${INTERNAL_PURGE_SECRET}" \ --from-literal=purge-secret="${INTERNAL_PURGE_SECRET}" \
--from-literal=stripe-secret-key="${STRIPE_SECRET_KEY}" \ --from-literal=stripe-secret-key="${STRIPE_SECRET_KEY}" \
--from-literal=stripe-webhook-secret="${STRIPE_WEBHOOK_SECRET}" \ --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 - --dry-run=client -o yaml | kubectl apply -n "${KUBE_NAMESPACE}" -f -
kubectl kustomize "k8s/overlays/${DEPLOYMENT_ENVIRONMENT}" \ kubectl kustomize "k8s/overlays/${DEPLOYMENT_ENVIRONMENT}" \
+2 -1
View File
@@ -37,5 +37,6 @@ jobs:
INTERNAL_PURGE_SECRET: ${{ secrets.PRD_INTERNAL_PURGE_SECRET }} INTERNAL_PURGE_SECRET: ${{ secrets.PRD_INTERNAL_PURGE_SECRET }}
STRIPE_SECRET_KEY: ${{ secrets.PRD_STRIPE_SECRET_KEY }} STRIPE_SECRET_KEY: ${{ secrets.PRD_STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.PRD_STRIPE_WEBHOOK_SECRET }} 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 run: bash .gitea/scripts/deploy-environment.sh
+2 -1
View File
@@ -37,5 +37,6 @@ jobs:
INTERNAL_PURGE_SECRET: ${{ secrets.UAT_INTERNAL_PURGE_SECRET }} INTERNAL_PURGE_SECRET: ${{ secrets.UAT_INTERNAL_PURGE_SECRET }}
STRIPE_SECRET_KEY: ${{ secrets.UAT_STRIPE_SECRET_KEY }} STRIPE_SECRET_KEY: ${{ secrets.UAT_STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.UAT_STRIPE_WEBHOOK_SECRET }} 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 run: bash .gitea/scripts/deploy-environment.sh
+47
View File
@@ -86,6 +86,53 @@ public sealed class AuthTests
Assert.Null(SteamOpenIdService.ExtractSteamId("https://steamcommunity.com/openid/id/123")); Assert.Null(SteamOpenIdService.ExtractSteamId("https://steamcommunity.com/openid/id/123"));
} }
[Fact]
public void GoogleProviderIsDisabledUntilAllSecureConfigurationExists()
{
var incomplete = new GoogleExternalAuthProvider(new TestHttpClientFactory(), Configuration(new Dictionary<string, string?>
{
["Google:ClientId"] = "client-id",
["Google:ReturnUrl"] = "https://example.test/api/Auth/external/google/callback"
}));
var complete = new GoogleExternalAuthProvider(new TestHttpClientFactory(), Configuration(new Dictionary<string, string?>
{
["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<string, string?>
{
["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<string, string?> values) => private static IConfiguration Configuration(Dictionary<string, string?> values) =>
new ConfigurationBuilder().AddInMemoryCollection(values).Build(); new ConfigurationBuilder().AddInMemoryCollection(values).Build();
private sealed class TestHttpClientFactory : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new();
}
} }
+20 -2
View File
@@ -20,6 +20,9 @@ public sealed class AuthController(
UserProfileStore profiles, UserProfileStore profiles,
PasswordResetStore passwordResets, PasswordResetStore passwordResets,
SteamAuthTransactionStore steamTransactions, SteamAuthTransactionStore steamTransactions,
ExternalIdentityStore externalIdentities,
ExternalAuthTransactionStore externalTransactions,
IEnumerable<IExternalAuthProvider> externalProviders,
PasswordResetCodeService resetCodes, PasswordResetCodeService resetCodes,
IPasswordResetEmailSender resetEmail, IPasswordResetEmailSender resetEmail,
IConfiguration configuration, IConfiguration configuration,
@@ -109,7 +112,7 @@ public sealed class AuthController(
[HttpGet("me")] [HttpGet("me")]
[Authorize(Roles = "USER,SUPER")] [Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Me() => public async Task<IActionResult> Me() =>
await CurrentUserAsync() is { } user ? Ok(ProfileResponse(user)) : Unauthorized(); await CurrentUserAsync() is { } user ? Ok(await ProfileResponseAsync(user)) : Unauthorized();
[HttpPost("legal/accept")] [HttpPost("legal/accept")]
[Authorize(Roles = "USER,SUPER")] [Authorize(Roles = "USER,SUPER")]
@@ -152,7 +155,7 @@ public sealed class AuthController(
{ {
return Conflict("Email address is already in use."); return Conflict("Email address is already in use.");
} }
return Ok(ProfileResponse(user)); return Ok(await ProfileResponseAsync(user));
} }
[HttpPost("me/change-password")] [HttpPost("me/change-password")]
@@ -186,6 +189,7 @@ public sealed class AuthController(
await profiles.PurgeAsync(user.Id); await profiles.PurgeAsync(user.Id);
await passwordResets.DeleteForUserAsync(user.Id); await passwordResets.DeleteForUserAsync(user.Id);
await steamTransactions.DeleteForUserAsync(user.Id); await steamTransactions.DeleteForUserAsync(user.Id);
await externalTransactions.DeleteForUserAsync(user.Id);
await RevokeCurrentTokenAsync(); await RevokeCurrentTokenAsync();
await users.DeleteAsync(user.Id); await users.DeleteAsync(user.Id);
return NoContent(); return NoContent();
@@ -302,6 +306,20 @@ public sealed class AuthController(
legal = legal.Status(user) legal = legal.Status(user)
}; };
private async Task<object> 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, private object SessionResponse(AuthUser user, string accessToken, DateTime expiresUtc,
string? jti = null, string? refreshToken = null) => new string? jti = null, string? refreshToken = null) => new
{ {
@@ -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<IExternalAuthProvider> providerServices,
ExternalIdentityStore identities,
ExternalAuthTransactionStore transactions,
AuthUserStore users,
JwtTokenService tokens,
LegalPolicy legal,
IConfiguration configuration,
ILogger<ExternalAuthController> logger) : ControllerBase
{
private readonly Dictionary<string, IExternalAuthProvider> _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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<ExternalAuthTransaction?> 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 = $$"""
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>RanaZe {{safeProvider}} sign-in</title><style>body{font-family:system-ui;background:#081324;color:#f8fafc;display:grid;place-items:center;min-height:100vh;margin:0}.card{max-width:34rem;padding:2rem;border:1px solid #33415d;border-radius:8px}h1{color:{{color}};font-size:1.4rem}</style></head>
<body><main class="card"><h1>{{safeProvider}} sign-in {{(success ? "complete" : "failed")}}</h1><p>{{safeMessage}}</p></main>
<script>if(window.opener){setTimeout(()=>window.close(),1200);}</script></body></html>
""";
return Content(html, "text/html; charset=utf-8");
}
private static string Base64Url(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
@@ -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);
+7
View File
@@ -49,6 +49,10 @@ builder.Services.AddSingleton<JwtTokenService>();
builder.Services.AddSingleton<LegalPolicy>(); builder.Services.AddSingleton<LegalPolicy>();
builder.Services.AddSingleton<SteamAuthTransactionStore>(); builder.Services.AddSingleton<SteamAuthTransactionStore>();
builder.Services.AddSingleton<SteamOpenIdService>(); builder.Services.AddSingleton<SteamOpenIdService>();
builder.Services.AddSingleton<ExternalIdentityStore>();
builder.Services.AddSingleton<ExternalAuthTransactionStore>();
builder.Services.AddSingleton<IExternalAuthProvider, SteamExternalAuthProvider>();
builder.Services.AddSingleton<IExternalAuthProvider, GoogleExternalAuthProvider>();
builder.Services.AddSingleton<PasswordResetStore>(); builder.Services.AddSingleton<PasswordResetStore>();
builder.Services.AddSingleton<PasswordResetCodeService>(); builder.Services.AddSingleton<PasswordResetCodeService>();
builder.Services.AddSingleton<IPasswordResetEmailSender, SmtpPasswordResetEmailSender>(); builder.Services.AddSingleton<IPasswordResetEmailSender, SmtpPasswordResetEmailSender>();
@@ -78,6 +82,9 @@ builder.Services.AddRateLimiter(options =>
options.AddPolicy("steam-start", context => Fixed(context, 10, TimeSpan.FromMinutes(1))); 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-poll", context => Fixed(context, 120, TimeSpan.FromMinutes(1)));
options.AddPolicy("steam-complete", context => Fixed(context, 20, 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; options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
}); });
@@ -8,12 +8,17 @@ public sealed class AuthDataInitializer(
AuthUserStore users, AuthUserStore users,
TokenBlacklistStore blacklist, TokenBlacklistStore blacklist,
SteamAuthTransactionStore steamTransactions, SteamAuthTransactionStore steamTransactions,
ExternalIdentityStore externalIdentities,
ExternalAuthTransactionStore externalTransactions,
PasswordResetStore passwordResets, PasswordResetStore passwordResets,
ILogger<AuthDataInitializer> logger) ILogger<AuthDataInitializer> logger)
{ {
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
await BackfillNormalizedEmailsAsync(); await BackfillNormalizedEmailsAsync();
await externalIdentities.BackfillSteamAsync(logger);
await externalIdentities.EnsureIndexesAsync();
await externalTransactions.EnsureIndexesAsync();
await users.Collection.Indexes.CreateManyAsync([ await users.Collection.Indexes.CreateManyAsync([
new CreateIndexModel<AuthUser>( new CreateIndexModel<AuthUser>(
@@ -70,6 +70,15 @@ public sealed class AuthUserStore(MongoDatabaseProvider mongo)
return (await _users.UpdateOneAsync(filter, update)).MatchedCount == 1; return (await _users.UpdateOneAsync(filter, update)).MatchedCount == 1;
} }
public async Task<bool> TrySetSteamIdAsync(string userId, string steamId)
{
var filter = Builders<AuthUser>.Filter.Eq(user => user.Id, userId) &
(Builders<AuthUser>.Filter.Eq(user => user.SteamId, null) |
Builders<AuthUser>.Filter.Eq(user => user.SteamId, steamId));
return (await _users.UpdateOneAsync(filter,
Builders<AuthUser>.Update.Set(user => user.SteamId, steamId))).MatchedCount == 1;
}
public async Task<bool> ResetPasswordAsync(string userId, string passwordHash) public async Task<bool> ResetPasswordAsync(string userId, string passwordHash)
{ {
var update = Builders<AuthUser>.Update var update = Builders<AuthUser>.Update
@@ -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<ExternalProviderClaims?> 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<ExternalProviderClaims?> 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<OpenIdConnectConfiguration> _discovery;
public GoogleExternalAuthProvider(IHttpClientFactory httpClients, IConfiguration configuration)
{
_httpClients = httpClients;
_configuration = configuration;
_discovery = new ConfigurationManager<OpenIdConnectConfiguration>(
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<string, string>
{
["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<ExternalProviderClaims?> 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<string, string>
{
["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<string, string> 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('/', '_');
}
@@ -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<ExternalIdentity> _identities =
mongo.Database.GetCollection<ExternalIdentity>("ExternalIdentities");
public async Task<ExternalIdentity?> GetAsync(string provider, string subject) =>
await _identities.Find(value => value.Provider == provider && value.Subject == subject)
.FirstOrDefaultAsync();
public async Task<ExternalIdentity?> GetForUserAsync(string userId, string provider) =>
await _identities.Find(value => value.UserId == userId && value.Provider == provider)
.FirstOrDefaultAsync();
public async Task<List<string>> 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<ExternalIdentity>.Update.Set(value => value.LastUsedUtc, DateTime.UtcNow));
public async Task<bool> 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<ExternalIdentity>.Filter.Eq(value => value.Provider, ExternalAuthProviders.Steam) &
Builders<ExternalIdentity>.Filter.Eq(value => value.Subject, user.SteamId!);
var update = Builders<ExternalIdentity>.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<ExternalIdentity>(
Builders<ExternalIdentity>.IndexKeys.Ascending(value => value.Provider)
.Ascending(value => value.Subject),
new CreateIndexOptions { Unique = true, Name = "external_identity_provider_subject" }),
new CreateIndexModel<ExternalIdentity>(
Builders<ExternalIdentity>.IndexKeys.Ascending(value => value.UserId)
.Ascending(value => value.Provider),
new CreateIndexOptions { Unique = true, Name = "external_identity_user_provider" }),
new CreateIndexModel<ExternalIdentity>(
Builders<ExternalIdentity>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Name = "external_identity_user" })
]);
}
internal IMongoCollection<ExternalIdentity> Collection => _identities;
}
public sealed class ExternalAuthTransactionStore(MongoDatabaseProvider mongo)
{
private readonly IMongoCollection<ExternalAuthTransaction> _transactions =
mongo.Database.GetCollection<ExternalAuthTransaction>("ExternalAuthTransactions");
public Task CreateAsync(ExternalAuthTransaction transaction) => _transactions.InsertOneAsync(transaction);
public async Task<ExternalAuthTransaction?> GetAsync(string id) =>
await _transactions.Find(value => value.Id == id).FirstOrDefaultAsync();
public async Task<bool> MarkVerifiedAsync(
string id,
ExternalProviderClaims claims,
string status,
string? userId,
string? error = null)
{
var filter = Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Id, id) &
Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Status, "pending") &
Builders<ExternalAuthTransaction>.Filter.Gt(value => value.ExpiresUtc, DateTime.UtcNow);
var update = Builders<ExternalAuthTransaction>.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<bool> MarkCompleteAsync(string id, string subject, string userId)
{
var filter = Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Id, id) &
Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Status, "requires_account") &
Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Subject, subject) &
Builders<ExternalAuthTransaction>.Filter.Gt(value => value.ExpiresUtc, DateTime.UtcNow);
var update = Builders<ExternalAuthTransaction>.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<ExternalAuthTransaction>(
Builders<ExternalAuthTransaction>.IndexKeys.Ascending(value => value.ExpiresUtc),
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "external_auth_expiry" }));
internal IMongoCollection<ExternalAuthTransaction> Collection => _transactions;
}
@@ -15,7 +15,8 @@ public sealed class UserProfileStore
"QuestionAttempts", "UserRatings", "QuestionBookmarks", "UserNotepads", "QuestionAttempts", "UserRatings", "QuestionBookmarks", "UserNotepads",
"ReviewQueueItems", "QuestionReviewAttempts", "ReviewQueueBackfillStates", "ReviewQueueItems", "QuestionReviewAttempts", "ReviewQueueBackfillStates",
"CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations", "CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations",
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles" "Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
"ExternalIdentities", "ExternalAuthTransactions"
]; ];
public UserProfileStore(MongoDatabaseProvider mongo) public UserProfileStore(MongoDatabaseProvider mongo)
+34
View File
@@ -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.
+10 -1
View File
@@ -56,6 +56,16 @@ spec:
secretKeyRef: secretKeyRef:
name: step3-runtime-secrets name: step3-runtime-secrets
key: stripe-webhook-secret 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: resources:
requests: requests:
cpu: 250m cpu: 250m
@@ -79,4 +89,3 @@ spec:
periodSeconds: 20 periodSeconds: 20
timeoutSeconds: 3 timeoutSeconds: 3
failureThreshold: 3 failureThreshold: 3
+2
View File
@@ -20,6 +20,8 @@ configMapGenerator:
- PasswordReset__Enabled=false - PasswordReset__Enabled=false
- Steam__Realm=https://s3q.ranaze.com - Steam__Realm=https://s3q.ranaze.com
- Steam__ReturnUrl=https://s3q.ranaze.com/api/Auth/steam/callback - 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__TermsVersion=2026-07-20
- Legal__PrivacyVersion=2026-07-20 - Legal__PrivacyVersion=2026-07-20
- Legal__EffectiveUtc=2026-07-20T00:00:00Z - Legal__EffectiveUtc=2026-07-20T00:00:00Z
+2 -1
View File
@@ -17,6 +17,8 @@ configMapGenerator:
- PasswordReset__Enabled=false - PasswordReset__Enabled=false
- Steam__Realm=https://us3q.ranaze.com - Steam__Realm=https://us3q.ranaze.com
- Steam__ReturnUrl=https://us3q.ranaze.com/api/Auth/steam/callback - 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__TermsVersion=2026-07-20
- Legal__PrivacyVersion=2026-07-20 - Legal__PrivacyVersion=2026-07-20
- Legal__EffectiveUtc=2026-07-20T00:00:00Z - Legal__EffectiveUtc=2026-07-20T00:00:00Z
@@ -26,4 +28,3 @@ configMapGenerator:
- Stripe__CancelUrl=https://ustep3.ranaze.com/donation-canceled.html - Stripe__CancelUrl=https://ustep3.ranaze.com/donation-canceled.html
generatorOptions: generatorOptions:
disableNameSuffixHash: true disableNameSuffixHash: true
+119
View File
@@ -6,6 +6,8 @@ signal preferences_saved
signal password_changed signal password_changed
signal account_deleted signal account_deleted
signal archive_saved(path: String) 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 EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const AUTH_URL := "https://us3q.ranaze.com/api/Auth" const AUTH_URL := "https://us3q.ranaze.com/api/Auth"
@@ -15,11 +17,18 @@ var account: Dictionary = {}
var preferences: Dictionary = _default_preferences() var preferences: Dictionary = _default_preferences()
var auth_request: HTTPRequest var auth_request: HTTPRequest
var profile_request: HTTPRequest var profile_request: HTTPRequest
var external_request: HTTPRequest
var external_poll_timer: Timer
var auth_operation := "" var auth_operation := ""
var profile_operation := "" var profile_operation := ""
var auth_token := "" var auth_token := ""
var profile_token := "" var profile_token := ""
var preferences_pending := false 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: func _ready() -> void:
@@ -31,6 +40,14 @@ func _ready() -> void:
profile_request.timeout = 30.0 profile_request.timeout = 30.0
profile_request.request_completed.connect(_on_profile_completed) profile_request.request_completed.connect(_on_profile_completed)
add_child(profile_request) 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) 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: func _request_auth(operation: String, method: HTTPClient.Method, path: String, payload: Variant = null) -> void:
if auth_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: if auth_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
request_failed.emit("Another account request is still in progress.") 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, " ")) _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: func _save_archive(content: String) -> void:
var filename := "ranaze-account-export-%s.json" % Time.get_date_string_from_system() var filename := "ranaze-account-export-%s.json" % Time.get_date_string_from_system()
if OS.has_feature("web"): if OS.has_feature("web"):
+78 -29
View File
@@ -3,7 +3,7 @@ extends Control
const Brand := preload("res://scripts/ui/medmind_style.gd") const Brand := preload("res://scripts/ui/medmind_style.gd")
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const AUTH_BASE := "https://us3q.ranaze.com/api/Auth" const AUTH_BASE := "https://us3q.ranaze.com/api/Auth"
const STEAM_AUTH_STORAGE_KEY := "ranaze_steam_auth_url" const EXTERNAL_AUTH_STORAGE_PREFIX := "ranaze_external_auth_url_"
var main: Control var main: Control
var auth_base_url := AUTH_BASE var auth_base_url := AUTH_BASE
@@ -38,6 +38,7 @@ var steam_username_input: LineEdit
var steam_email_input: LineEdit var steam_email_input: LineEdit
var steam_password_input: LineEdit var steam_password_input: LineEdit
var steam_legal_check: CheckBox var steam_legal_check: CheckBox
var external_buttons: Dictionary = {}
var panel_gutter: MarginContainer var panel_gutter: MarginContainer
var submit_buttons: Array[Button] = [] var submit_buttons: Array[Button] = []
var fields: Array[LineEdit] = [] var fields: Array[LineEdit] = []
@@ -49,6 +50,9 @@ var steam_transaction_id := ""
var steam_poll_deadline_msec := 0 var steam_poll_deadline_msec := 0
var steam_poll_failures := 0 var steam_poll_failures := 0
var steam_popup_opened := false var steam_popup_opened := false
var external_provider_id := ""
var external_provider_name := ""
var external_verified_email := ""
var automatic_login_pending := false var automatic_login_pending := false
var login_attempt_username := "" var login_attempt_username := ""
var compact_layout := false var compact_layout := false
@@ -63,6 +67,7 @@ func _ready() -> void:
_create_request("register", _on_register_completed) _create_request("register", _on_register_completed)
_create_request("reset_request", _on_reset_requested) _create_request("reset_request", _on_reset_requested)
_create_request("reset_confirm", _on_reset_confirmed) _create_request("reset_confirm", _on_reset_confirmed)
_create_request("providers", _on_providers_loaded)
_create_request("steam_start", _on_steam_started) _create_request("steam_start", _on_steam_started)
_create_request("steam_status", _on_steam_status) _create_request("steam_status", _on_steam_status)
_create_request("steam_complete", _on_steam_completed) _create_request("steam_complete", _on_steam_completed)
@@ -76,6 +81,7 @@ func _ready() -> void:
_update_responsive_layout.call_deferred() _update_responsive_layout.call_deferred()
_set_mode("login") _set_mode("login")
_apply_theme() _apply_theme()
_load_external_providers()
func on_show(payload: Variant = null) -> void: 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) var sign_in := _add_primary_button(view, "Sign In", _on_login_pressed)
password_input.text_submitted.connect(func(_text: String): _on_login_pressed()) password_input.text_submitted.connect(func(_text: String): _on_login_pressed())
view.add_child(_build_divider()) view.add_child(_build_divider())
var steam := Button.new() for provider in [["steam", "Steam"], ["google", "Google"]]:
steam.text = "Sign in with Steam" var external := Button.new()
steam.custom_minimum_size = Vector2(0, 68) external.text = "Sign in with %s" % provider[1]
steam.pressed.connect(_start_steam) external.custom_minimum_size = Vector2(0, 68)
view.add_child(steam) external.visible = false
submit_buttons.append(steam) 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() var create := Button.new()
create.text = "New to RanaZe? Create an account" create.text = "New to RanaZe? Create an account"
create.flat = true create.flat = true
@@ -298,7 +307,7 @@ func _build_reset_view() -> VBoxContainer:
func _build_steam_view() -> VBoxContainer: func _build_steam_view() -> VBoxContainer:
var view := _new_form() var view := _new_form()
var help := Label.new() 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 help.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
view.add_child(help) view.add_child(help)
steam_action = OptionButton.new() steam_action = OptionButton.new()
@@ -312,7 +321,7 @@ func _build_steam_view() -> VBoxContainer:
_add_show_password(view, [steam_password_input]) _add_show_password(view, [steam_password_input])
steam_legal_check = _add_legal_consent(view) steam_legal_check = _add_legal_consent(view)
_add_primary_button(view, "Continue", _complete_steam) _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 return view
@@ -461,7 +470,7 @@ func _set_mode(next_mode: String) -> void:
match mode: match mode:
"register": heading.text = "Create your account"; subheading.text = "Start studying with RanaZe" "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" "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" _: heading.text = "Welcome back"; subheading.text = "Sign in to continue"
_clear_message() _clear_message()
_update_responsive_layout() _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("normal", Brand.secondary_button_box(dark))
typed.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) typed.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true))
for button in submit_buttons: 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_stylebox_override("normal", Brand.gradient_button_box())
button.add_theme_color_override("font_color", Color.WHITE) button.add_theme_color_override("font_color", Color.WHITE)
var theme_normal := StyleBoxFlat.new() 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.") _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: 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"): if OS.has_feature("web"):
var popup_result: Variant = JavaScriptBridge.eval( 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 true
) )
steam_popup_opened = bool(popup_result) steam_popup_opened = bool(popup_result)
if not steam_popup_opened: if not steam_popup_opened:
return _show_error("Steam sign in needs a popup. Allow popups for this site, then try again.") return _show_error("%s sign in needs a popup. Allow popups for this site, then try again." % provider_name)
_send_json("steam_start", "/steam/start", {}, "Opening Steam...") _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: func _on_steam_started(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_set_busy(false) _set_busy(false)
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300: if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
_close_steam_popup() _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) var data := _parse_dictionary(body)
steam_transaction_id = str(data.get("transactionId", "")) steam_transaction_id = str(data.get("transactionId", ""))
var auth_url := str(data.get("authUrl", "")) var auth_url := str(data.get("authUrl", ""))
if steam_transaction_id.is_empty() or auth_url.is_empty(): if steam_transaction_id.is_empty() or auth_url.is_empty():
_close_steam_popup() _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"): if OS.has_feature("web"):
JavaScriptBridge.eval( 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 true
) )
else: else:
OS.shell_open(auth_url) OS.shell_open(auth_url)
steam_poll_deadline_msec = Time.get_ticks_msec() + 300000 steam_poll_deadline_msec = Time.get_ticks_msec() + 300000
steam_poll_failures = 0 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() 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: if steam_poll_deadline_msec > 0 and Time.get_ticks_msec() >= steam_poll_deadline_msec:
_reset_steam_flow() _reset_steam_flow()
return _show_error("Steam sign in timed out. Try again when ready.") 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: if error != OK:
steam_poll_failures += 1 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: func _on_steam_status(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS or code >= 500 or code == 429: if result != HTTPRequest.RESULT_SUCCESS or code >= 500 or code == 429:
steam_poll_failures += 1 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 return
if code == 404: if code == 404:
_reset_steam_flow() _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: if code < 200 or code >= 300:
_reset_steam_flow() _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 steam_poll_failures = 0
var data := _parse_dictionary(body) var data := _parse_dictionary(body)
match str(data.get("status", "pending")): match str(data.get("status", "pending")):
"pending": _show_success("Complete sign in in the Steam window. Waiting for verification...") "pending": _show_success("Complete sign in in the %s window. Waiting for verification..." % external_provider_name)
"requires_account": poll_timer.stop(); _close_steam_popup(); _set_mode("steam") "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", ""))) "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.") 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(): elif steam_username_input.text.strip_edges().is_empty() or steam_password_input.text.is_empty():
return _show_error("Enter your existing account credentials.") 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, "transactionId": steam_transaction_id, "action": action,
"username": steam_username_input.text.strip_edges(), "email": steam_email_input.text.strip_edges(), "username": steam_username_input.text.strip_edges(), "email": steam_email_input.text.strip_edges(),
"password": steam_password_input.text, "acceptLegal": action == "create" "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: func _on_steam_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_set_busy(false) _set_busy(false)
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300: 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 steam_poll_failures = 0
poll_timer.start() poll_timer.start()
_poll_steam() _poll_steam()
@@ -748,18 +790,25 @@ func _on_steam_completed(result: int, code: int, _headers: PackedStringArray, bo
func _reset_steam_flow() -> void: func _reset_steam_flow() -> void:
if poll_timer != null: poll_timer.stop() if poll_timer != null: poll_timer.stop()
_close_steam_popup()
steam_transaction_id = "" steam_transaction_id = ""
steam_poll_deadline_msec = 0 steam_poll_deadline_msec = 0
steam_poll_failures = 0 steam_poll_failures = 0
_close_steam_popup() external_provider_id = ""
external_provider_name = ""
external_verified_email = ""
func _close_steam_popup() -> void: func _close_steam_popup() -> void:
if OS.has_feature("web") and steam_popup_opened: 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 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: func _accept_session(body: PackedByteArray, fallback_username: String) -> void:
var response := _parse_dictionary(body) var response := _parse_dictionary(body)
var token := str(response.get("accessToken", "")) var token := str(response.get("accessToken", ""))
+47
View File
@@ -18,6 +18,7 @@ var outcome_date: LineEdit
var outcome_consent: CheckBox var outcome_consent: CheckBox
var outcome_status: Label var outcome_status: Label
var outcome_delete: Button var outcome_delete: Button
var connected_accounts_list: VBoxContainer
func _ready() -> void: func _ready() -> void:
@@ -26,6 +27,8 @@ func _ready() -> void:
AccountProfileService.request_failed.connect(_show_error) AccountProfileService.request_failed.connect(_show_error)
AccountProfileService.preferences_saved.connect(func(): _show_saved("Study identity saved.")) AccountProfileService.preferences_saved.connect(func(): _show_saved("Study identity saved."))
AccountProfileService.archive_saved.connect(func(path: String): _show_saved("Archive saved: %s" % path)) 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_changed.connect(_render)
DashboardService.dashboard_failed.connect(_show_error) DashboardService.dashboard_failed.connect(_show_error)
ExamSimulationService.outcome_changed.connect(_render_outcome) ExamSimulationService.outcome_changed.connect(_render_outcome)
@@ -82,6 +85,7 @@ func _build() -> void:
right.add_theme_constant_override("separation", 14) right.add_theme_constant_override("separation", 14)
columns.add_child(right) columns.add_child(right)
right.add_child(_outcome_card()) right.add_child(_outcome_card())
right.add_child(_connected_accounts_card())
right.add_child(_account_actions_card()) right.add_child(_account_actions_card())
_update_responsive() _update_responsive()
_render() _render()
@@ -228,6 +232,16 @@ func _account_actions_card() -> PanelContainer:
return _card(body) 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: func _render() -> void:
if profile_name == null: if profile_name == null:
return return
@@ -241,6 +255,7 @@ func _render() -> void:
var role := str(account.get("role", AuthState.role)).capitalize() var role := str(account.get("role", AuthState.role)).capitalize()
var created := _date_only(str(account.get("createdUtc", ""))) var created := _date_only(str(account.get("createdUtc", "")))
profile_meta.text = role if created.is_empty() else "%s | Member since %s" % [role, created] 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["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["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)) (identity_values["goal"] as Label).text = "%d hours / week" % int(prefs.get("weeklyGoalHours", 10))
@@ -256,6 +271,38 @@ func _render() -> void:
_render_outcome() _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("Connect", "link", false)
connect.pressed.connect(AccountProfileService.connect_provider.bind(provider_id, provider_name))
row.add_child(connect)
connected_accounts_list.add_child(row)
func _render_systems(systems: Array = DashboardService.data.get("systems", [])) -> void: func _render_systems(systems: Array = DashboardService.data.get("systems", [])) -> void:
if systems_list == null: if systems_list == null:
return return