< Summary

Information
Class: RaidLoop.Client.Services.SupabaseAuthService
Assembly: RaidLoop.Client
File(s): /home/runner/work/RaidLoop/RaidLoop/src/RaidLoop.Client/Services/SupabaseAuthService.cs
Line coverage
12%
Covered lines: 22
Uncovered lines: 150
Coverable lines: 172
Total lines: 364
Line coverage: 12.7%
Branch coverage
5%
Covered branches: 4
Total branches: 76
Branch coverage: 5.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/RaidLoop/RaidLoop/src/RaidLoop.Client/Services/SupabaseAuthService.cs

#LineLine coverage
 1using System.Text.Json;
 2using Microsoft.AspNetCore.Components;
 3using Microsoft.Extensions.Options;
 4using Microsoft.JSInterop;
 5using RaidLoop.Client.Configuration;
 6using Supabase.Gotrue;
 7
 8namespace RaidLoop.Client.Services;
 9
 10public sealed class SupabaseAuthService : ISupabaseSessionProvider
 11{
 12    private const string SessionStorageKey = "raidloop.auth.session.v1";
 13    private const string PkceVerifierStorageKey = "raidloop.auth.pkce-verifier.v1";
 14
 15    private readonly IJSRuntime _jsRuntime;
 16    private readonly NavigationManager _navigationManager;
 17    private readonly IClientTelemetryService Telemetry;
 18    private readonly SupabaseOptions _options;
 19
 20    private Supabase.Client? _client;
 21    private bool _isInitialized;
 22    private bool _isSignedOutLocally;
 23
 224    public SupabaseAuthService(
 225        IJSRuntime jsRuntime,
 226        NavigationManager navigationManager,
 227        IClientTelemetryService telemetry,
 228        IOptions<SupabaseOptions> options)
 29    {
 230        _jsRuntime = jsRuntime;
 231        _navigationManager = navigationManager;
 232        Telemetry = telemetry;
 233        _options = options.Value;
 234    }
 35
 36    public event Action? AuthStateChanged;
 37
 038    public bool IsLoading { get; private set; }
 39
 140    public bool IsAuthenticated => !_isSignedOutLocally && _client?.Auth.CurrentSession is not null;
 41
 042    public string? UserEmail => _client?.Auth.CurrentUser?.Email;
 43
 044    public Supabase.Client? Client => _client;
 45
 46    public async Task InitializeAsync()
 47    {
 048        if (_isInitialized)
 49        {
 050            return;
 51        }
 52
 053        IsLoading = true;
 054        NotifyAuthStateChanged();
 55
 056        _client = new Supabase.Client(
 057            _options.Url,
 058            _options.PublishableKey,
 059            new Supabase.SupabaseOptions
 060            {
 061                AutoConnectRealtime = false
 062            });
 63
 064        await _client.InitializeAsync();
 065        _client.Auth.AddStateChangedListener((_, _) => _ = HandleAuthSessionChangedAsync());
 066        _isSignedOutLocally = false;
 67
 068        var currentUri = new Uri(_navigationManager.Uri);
 069        if (TryGetQueryParameter(currentUri, "code", out var code))
 70        {
 071            Session? session = null;
 072            var pkceVerifier = await LoadPkceVerifierAsync();
 073            if (!string.IsNullOrWhiteSpace(pkceVerifier))
 74            {
 75                try
 76                {
 077                    session = await _client.Auth.ExchangeCodeForSession(pkceVerifier, code);
 078                }
 079                catch (Exception ex)
 80                {
 081                    await ReportHandledErrorAsync("Supabase PKCE session exchange failed.", "auth-session", ex);
 082                    throw;
 83                }
 84            }
 85
 086            if (session is not null)
 87            {
 088                await PersistSessionAsync(session);
 89            }
 90
 091            await ClearPkceVerifierAsync();
 092            _navigationManager.NavigateTo(GetCurrentPathWithoutQueryOrFragment(), replace: true);
 093        }
 94        else
 95        {
 096            var persisted = await LoadPersistedSessionAsync();
 097            if (persisted is not null)
 98            {
 99                try
 100                {
 0101                    await _client.Auth.SetSession(persisted.AccessToken, persisted.RefreshToken, false);
 0102                }
 0103                catch (Exception ex)
 104                {
 0105                    await ReportHandledErrorAsync("Supabase session restore failed.", "auth-session", ex);
 0106                    await ClearPersistedSessionAsync();
 107                }
 108            }
 109        }
 110
 0111        if (_client.Auth.CurrentSession is not null)
 112        {
 0113            _isSignedOutLocally = false;
 0114            await PersistSessionAsync(_client.Auth.CurrentSession);
 115        }
 116
 0117        _isInitialized = true;
 0118        IsLoading = false;
 0119        NotifyAuthStateChanged();
 0120    }
 121
 122    public async Task SignInWithGoogleAsync()
 123    {
 0124        if (_client is null)
 125        {
 0126            await InitializeAsync();
 127        }
 128
 0129        _isSignedOutLocally = false;
 130
 0131        var providerState = await _client!.Auth.SignIn(
 0132            Supabase.Gotrue.Constants.Provider.Google,
 0133            new SignInOptions
 0134            {
 0135                FlowType = Constants.OAuthFlowType.PKCE,
 0136                RedirectTo = GetCurrentUriWithoutQueryOrFragment()
 0137            });
 138
 0139        if (!string.IsNullOrWhiteSpace(providerState.PKCEVerifier))
 140        {
 0141            await PersistPkceVerifierAsync(providerState.PKCEVerifier);
 142        }
 143
 0144        _navigationManager.NavigateTo(providerState.Uri.ToString(), forceLoad: true);
 0145    }
 146
 147    public async Task SignInWithEmailPasswordAsync(string email, string password)
 148    {
 0149        if (_client is null)
 150        {
 0151            await InitializeAsync();
 152        }
 153
 0154        var session = await _client!.Auth.SignIn(email, password);
 0155        _isSignedOutLocally = false;
 156
 0157        if (session is not null)
 158        {
 0159            await PersistSessionAsync(session);
 160        }
 161
 0162        NotifyAuthStateChanged();
 0163    }
 164
 165    public async Task SignUpWithEmailPasswordAsync(string email, string password)
 166    {
 0167        if (_client is null)
 168        {
 0169            await InitializeAsync();
 170        }
 171
 0172        var session = await _client!.Auth.SignUp(email, password, new SignUpOptions());
 0173        _isSignedOutLocally = false;
 174
 0175        if (session is not null)
 176        {
 0177            await PersistSessionAsync(session);
 178        }
 179
 0180        NotifyAuthStateChanged();
 0181    }
 182
 183    public async Task SignOutAsync()
 184    {
 1185        if (_client?.Auth is not null)
 186        {
 187            try
 188            {
 0189                await _client.Auth.SignOut();
 0190            }
 0191            catch (Exception ex)
 192            {
 0193                await ReportHandledErrorAsync("Supabase remote sign-out failed.", "auth-session", ex);
 194                // Force a local sign-out path when the remote session is already invalid.
 195            }
 196        }
 197
 1198        _isSignedOutLocally = true;
 1199        await ClearPersistedSessionAsync();
 1200        await ClearPkceVerifierAsync();
 1201        NotifyAuthStateChanged();
 1202    }
 203
 204    public async Task<string> GetAccessTokenAsync()
 205    {
 0206        if (_client is null)
 207        {
 0208            await InitializeAsync();
 209        }
 210
 0211        if (_client is null)
 212        {
 0213            throw new InvalidOperationException("Supabase client is not available.");
 214        }
 215
 0216        var session = _client.Auth.CurrentSession;
 0217        if (session?.ExpiresAt().Subtract(TimeSpan.FromMinutes(1)) <= DateTime.UtcNow)
 218        {
 219            try
 220            {
 0221                await _client.Auth.RefreshSession();
 0222                session = _client.Auth.CurrentSession;
 0223            }
 0224            catch (Exception ex)
 225            {
 0226                await ReportHandledErrorAsync("Supabase session refresh failed.", "auth-session", ex);
 0227                _isSignedOutLocally = true;
 0228                await ClearPersistedSessionAsync();
 0229                NotifyAuthStateChanged();
 0230                throw new InvalidOperationException("Supabase session refresh failed.");
 231            }
 232        }
 233
 0234        var accessToken = session?.AccessToken;
 0235        if (string.IsNullOrWhiteSpace(accessToken))
 236        {
 0237            throw new InvalidOperationException("Supabase session is not available.");
 238        }
 239
 0240        return accessToken;
 0241    }
 242
 243    private async Task PersistSessionAsync(Session session)
 244    {
 0245        if (string.IsNullOrWhiteSpace(session.AccessToken) || string.IsNullOrWhiteSpace(session.RefreshToken))
 246        {
 0247            return;
 248        }
 249
 0250        var persisted = new PersistedSession(session.AccessToken, session.RefreshToken);
 0251        var payload = JsonSerializer.Serialize(persisted);
 0252        await _jsRuntime.InvokeVoidAsync("raidLoopStorage.save", SessionStorageKey, payload);
 0253    }
 254
 255    private async Task<PersistedSession?> LoadPersistedSessionAsync()
 256    {
 0257        var payload = await _jsRuntime.InvokeAsync<string?>("raidLoopStorage.load", SessionStorageKey);
 0258        if (string.IsNullOrWhiteSpace(payload))
 259        {
 0260            return null;
 261        }
 262
 263        try
 264        {
 0265            return JsonSerializer.Deserialize<PersistedSession>(payload);
 266        }
 0267        catch
 268        {
 0269            return null;
 270        }
 0271    }
 272
 273    private async Task ClearPersistedSessionAsync()
 274    {
 1275        await _jsRuntime.InvokeVoidAsync("raidLoopStorage.remove", SessionStorageKey);
 1276    }
 277
 278    private async Task PersistPkceVerifierAsync(string verifier)
 279    {
 0280        await _jsRuntime.InvokeVoidAsync("raidLoopStorage.save", PkceVerifierStorageKey, verifier);
 0281    }
 282
 283    private Task<string?> LoadPkceVerifierAsync()
 284    {
 0285        return _jsRuntime.InvokeAsync<string?>("raidLoopStorage.load", PkceVerifierStorageKey).AsTask();
 286    }
 287
 288    private async Task ClearPkceVerifierAsync()
 289    {
 1290        await _jsRuntime.InvokeVoidAsync("raidLoopStorage.remove", PkceVerifierStorageKey);
 1291    }
 292
 293    private async Task HandleAuthSessionChangedAsync()
 294    {
 0295        if (_client?.Auth.CurrentSession is not null)
 296        {
 0297            _isSignedOutLocally = false;
 0298            await PersistSessionAsync(_client.Auth.CurrentSession);
 299        }
 300        else
 301        {
 0302            _isSignedOutLocally = true;
 0303            await ClearPersistedSessionAsync();
 304        }
 305
 0306        NotifyAuthStateChanged();
 0307    }
 308
 309    private void NotifyAuthStateChanged()
 310    {
 1311        AuthStateChanged?.Invoke();
 0312    }
 313
 314    private ValueTask ReportHandledErrorAsync(string message, string source, Exception? exception = null)
 315    {
 0316        return Telemetry.ReportErrorAsync(
 0317            message,
 0318            new
 0319            {
 0320                source,
 0321                exception = exception?.GetType().FullName,
 0322                exceptionMessage = exception?.Message,
 0323                stack = exception?.ToString()
 0324            });
 325    }
 326
 327    private string GetCurrentUriWithoutQueryOrFragment()
 328    {
 0329        var currentUri = new Uri(_navigationManager.Uri);
 0330        return currentUri.GetLeftPart(UriPartial.Path);
 331    }
 332
 333    private string GetCurrentPathWithoutQueryOrFragment()
 334    {
 0335        var currentUri = new Uri(_navigationManager.Uri);
 0336        var path = currentUri.GetLeftPart(UriPartial.Path);
 0337        return _navigationManager.ToBaseRelativePath(path) switch
 0338        {
 0339            "" => ".",
 0340            var relativePath => relativePath
 0341        };
 342    }
 343
 344    private static bool TryGetQueryParameter(Uri uri, string key, out string value)
 345    {
 0346        foreach (var pair in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
 347        {
 0348            var parts = pair.Split('=', 2);
 0349            if (!string.Equals(parts[0], key, StringComparison.OrdinalIgnoreCase))
 350            {
 351                continue;
 352            }
 353
 0354            value = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty;
 0355            return true;
 356        }
 357
 0358        value = string.Empty;
 0359        return false;
 360    }
 361
 0362    private sealed record PersistedSession(string AccessToken, string RefreshToken);
 363
 364}