diff --git a/Gotrue/Api.cs b/Gotrue/Api.cs index aa05b22..13bad34 100644 --- a/Gotrue/Api.cs +++ b/Gotrue/Api.cs @@ -16,11 +16,10 @@ namespace Supabase.Gotrue { public class Api : IGotrueApi { - protected string Url { get; private set; } + private string Url { get; } /// /// Function that can be set to return dynamic headers. - /// /// Headers specified in the constructor will ALWAYS take precedence over headers returned by this function. /// public Func>? GetHeaders { get; set; } @@ -39,14 +38,13 @@ protected Dictionary Headers } /// - /// Creates a new user using their email address. + /// Creates a new API client /// /// /// public Api(string url, Dictionary? headers = null) { Url = url; - headers ??= new Dictionary(); _headers = headers; } @@ -61,8 +59,7 @@ public Api(string url, Dictionary? headers = null) public async Task SignUpWithEmail(string email, string password, SignUpOptions? options = null) { var body = new Dictionary { { "email", email }, { "password", password } }; - - string endpoint = $"{Url}/signup"; + var endpoint = $"{Url}/signup"; if (options != null) { @@ -94,10 +91,7 @@ public Api(string url, Dictionary? headers = null) return session; } - else - { - return null; - } + return null; } /// @@ -138,12 +132,12 @@ public async Task SignInWithOtp(SignInWithPasswordlessE { { "email", options.Email }, { "data", options.Data }, - { "create_user", options.ShouldCreateUser }, + { "create_user", options.ShouldCreateUser } }; if (options.FlowType == OAuthFlowType.PKCE) { - string challenge = Helpers.GenerateNonce(); + var challenge = Helpers.GenerateNonce(); verifier = Helpers.GeneratePKCENonceVerifier(challenge); body.Add("code_challenge", challenge); @@ -205,16 +199,18 @@ public async Task SignInWithOtp(SignInWithPasswordlessP /// /// /// - /// + /// + /// InvalidProviderException + /// public Task SignInWithIdToken(Provider provider, string idToken, string? nonce = null, string? captchaToken = null) { if (provider != Provider.Google && provider != Provider.Apple) - throw new InvalidProviderException($"Provider must either be: `Provider.Google` or `Provider.Apple`."); + throw new GotrueException($"Provider must be `Provider.Google` or `Provider.Apple` not {provider}"); var body = new Dictionary { {"provider", Core.Helpers.GetMappedToAttr(provider).Mapping }, - {"id_token", idToken }, + {"id_token", idToken } }; if (!string.IsNullOrEmpty(nonce)) @@ -237,7 +233,7 @@ public Task SendMagicLinkEmail(string email, SignInOptions? option { var data = new Dictionary { { "email", email } }; - string endpoint = $"{Url}/magiclink"; + var endpoint = $"{Url}/magiclink"; if (options != null) { @@ -304,7 +300,7 @@ public Task InviteUserByEmail(string email, string jwt) { var data = new Dictionary { { "phone", phone }, - { "password", password }, + { "password", password } }; return Helpers.MakeRequest(HttpMethod.Post, $"{Url}/token?grant_type=password", data, Headers); } @@ -406,9 +402,9 @@ public ProviderAuthState GetUriForProvider(Provider provider, SignInOptions? opt result.PKCEVerifier = codeVerifier; } - if (attr is MapToAttribute mappedAttr) + if (attr is MapToAttribute) { - query.Add("provider", mappedAttr.Mapping); + query.Add("provider", attr.Mapping); if (!string.IsNullOrEmpty(options.Scopes)) query.Add("scopes", options.Scopes); @@ -500,7 +496,7 @@ public Task SignOut(string jwt) /// /// A valid JWT. Must be a full-access API key (e.g. service_role key). /// A string for example part of the email - /// Snake case string of the given key, currently only created_at is suppported + /// Snake case string of the given key, currently only created_at is supported /// asc or desc, if null desc is used /// page to show for pagination /// items per page for pagination @@ -548,10 +544,7 @@ private Dictionary TransformListUsersParams(string? filter = nul /// public Task CreateUser(string jwt, AdminUserAttributes? attributes = null) { - if (attributes == null) - { - attributes = new AdminUserAttributes(); - } + attributes ??= new AdminUserAttributes(); return Helpers.MakeRequest(HttpMethod.Post, $"{Url}/admin/users", attributes, CreateAuthedRequestHeaders(jwt)); } @@ -580,6 +573,11 @@ public Task DeleteUser(string uid, string jwt) return Helpers.MakeRequest(HttpMethod.Delete, $"{Url}/admin/users/{uid}", data, CreateAuthedRequestHeaders(jwt)); } + public Task Settings() + { + return Helpers.MakeRequest(HttpMethod.Get, $"{Url}/settings"); + } + /// /// Generates a new JWT /// diff --git a/Gotrue/Client.cs b/Gotrue/Client.cs index 8587ae4..1cc5bc1 100644 --- a/Gotrue/Client.cs +++ b/Gotrue/Client.cs @@ -1,17 +1,24 @@ -using System; +using System; using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using System.Web; -using Supabase.Gotrue.Exceptions; using Supabase.Gotrue.Interfaces; using static Supabase.Gotrue.Constants; +using static Supabase.Gotrue.Constants.AuthState; namespace Supabase.Gotrue { /// - /// The Gotrue Instance + /// GoTrue stateful Client. + /// + /// This class is best used as a long-lived singleton object in your application. You can attach listeners + /// to be notified of changes to the user log in state, a persistence system for sessions across application + /// launches, and more. It includes a (optional, on by default) background thread that runs to refresh the + /// user's session token. + /// + /// Check out the test suite for examples of use. /// /// /// var client = new Supabase.Gotrue.Client(options); @@ -19,124 +26,177 @@ namespace Supabase.Gotrue /// public class Client : IGotrueClient { - - private DebugNotification? _debugNotification; - - public void AddDebugListener(Action listener) - { - _debugNotification ??= new DebugNotification(); - _debugNotification.AddDebugListener(listener); - } - /// - /// Function that can be set to return dynamic headers. - /// - /// Headers specified in the client options will ALWAYS take precedence over headers returned by this function. + /// The underlying API requests object that sends the requests /// - - public Func>? GetHeaders - { - get => _getHeaders; - set - { - _getHeaders = value; - - if (_api != null) - _api.GetHeaders = value; - } - } - private Func>? _getHeaders; + private readonly IGotrueApi _api; /// - /// Event Handler that raises an event when a user signs in, signs out, recovers password, or updates their record. + /// Handlers for notifications of state changes. /// - public event EventHandler? StateChanged; + private readonly List.AuthEventHandler> _authEventHandlers = new List.AuthEventHandler>(); /// - /// The current User + /// Gets notifications if there is a failure not visible by exceptions (e.g. background thread refresh failure) /// - public User? CurrentUser { get; private set; } + private DebugNotification? _debugNotification; /// - /// The current Session + /// Internal timer reference for token refresh + /// + /// AutoRefreshToken + /// /// - public Session? CurrentSession { get; private set; } + private Timer? _refreshTimer; + /// - /// Should Client Refresh Token Automatically? (via ) + /// Object called to persist the session (e.g. filesystem or cookie) /// - protected bool AutoRefreshToken { get; private set; } + private IGotruePersistenceListener? _sessionPersistence; + /// - /// Should Client Persist Session? (via ) + /// Initializes the GoTrue stateful client. + /// + /// You will likely want to at least specify a + /// ClientOptions.Url + /// + /// + /// Sessions are not automatically retrieved when this object is created. + /// + /// If you want to load the session from your persistence store, + /// GotrueSessionPersistence + /// . + /// + /// If you want to load/refresh the session, + /// RetrieveSessionAsync + /// . + /// + /// For a typical client application, you'll want to load the session from persistence + /// and then refresh it. If your application is listening for session changes, you'll + /// get two SignIn notifications if the persisted session is valid - one for the + /// session loaded from disk, and a second on a successful session refresh. + /// + /// + /// + /// var client = new Supabase.Gotrue.Client(options); + /// client.LoadSession(); + /// await client.RetrieveSessionAsync(); + /// /// - protected bool ShouldPersistSession { get; private set; } + /// + public Client(ClientOptions? options = null) + { + options ??= new ClientOptions(); + Options = options; + _api = new Api(options.Url, options.Headers); + } /// - /// User defined function (via ) to persist the session. + /// Set the Session persistence system. Typically an application specific file system location. /// - // ReSharper disable once IdentifierTypo - protected Func> SessionPersistor { get; private set; } + /// + public void SetPersistence(IGotrueSessionPersistence persistence) + { + if (_sessionPersistence != null) + _authEventHandlers.Remove(_sessionPersistence.EventHandler); + _sessionPersistence = new PersistenceListener(persistence); + _authEventHandlers.Add(_sessionPersistence.EventHandler); + } /// - /// User defined function (via ) to retrieve the session. + /// The initialized client options. /// - protected Func> SessionRetriever { get; private set; } + public ClientOptions Options { get; } /// - /// User defined function (via ) to destroy the session. + /// Notifies all listeners that the current user auth state has changed. + /// + /// This is mainly used internally to fire notifications - most client applications won't need this. /// - protected Func> SessionDestroyer { get; private set; } + /// + public void NotifyAuthStateChange(AuthState stateChanged) + { + foreach (var handler in _authEventHandlers) + { + handler.Invoke(this, stateChanged); + } + } /// - /// The initialized client options. + /// The currently logged in User. This is a local cache of the current session User. + /// To persist modifications to the User you'll want to use other methods. + /// > /// - internal ClientOptions Options { get; private set; } + public User? CurrentUser + { + get => CurrentSession?.User; + } /// - /// Internal timer reference for Refreshing Tokens () + /// Adds a listener to be notified when the user state changes (e.g. the user logs in, logs out, + /// the token is refreshed, etc). + /// + /// /// - private Timer? _refreshTimer; + /// + public void AddStateChangedListener(IGotrueClient.AuthEventHandler authEventHandler) + { + if (_authEventHandlers.Contains(authEventHandler)) + return; - private IGotrueApi _api; + _authEventHandlers.Add(authEventHandler); + + } /// - /// Initializes the Client. - /// - /// Although options are ... optional, you will likely want to at least specify a . - /// - /// Sessions are no longer automatically retrieved on construction, if you want to set the session, - /// + /// Removes a specified listener from event state changes. /// - /// - public Client(ClientOptions? options = null) + /// + public void RemoveStateChangedListener(IGotrueClient.AuthEventHandler authEventHandler) { - if (options == null) - options = new ClientOptions(); + if (!_authEventHandlers.Contains(authEventHandler)) + return; - Options = options; - AutoRefreshToken = options.AutoRefreshToken; - ShouldPersistSession = options.PersistSession; - SessionPersistor = options.SessionPersistor; - SessionRetriever = options.SessionRetriever; - SessionDestroyer = options.SessionDestroyer; + _authEventHandlers.Remove(authEventHandler); + } - _api = new Api(options.Url, options.Headers); + /// + /// Clears all of the listeners from receiving event state changes. + /// + /// WARNING: The persistence handler is installed as a state change listener if provided in options. + /// Clearing the listeners will also delete the persistence handler. + /// + public void ClearStateChangedListeners() + { + _authEventHandlers.Clear(); } /// - /// Signs up a user by email address + /// The current Session as managed by this client. Does not refresh tokens or have any other side effects. + /// + /// You probably don't want to directly make changes to this object - you'll want to use other methods + /// on this class to make changes. + /// + public Session? CurrentSession { get; private set; } + + /// + /// Signs up a user by email address. /// /// /// By default, the user needs to verify their email address before logging in. To turn this off, disable Confirm email in your project. /// Confirm email determines if users need to confirm their email address after signing up. /// - If Confirm email is enabled, a user is returned but session is null. /// - If Confirm email is disabled, both a user and a session are returned. - /// When the user confirms their email address, they are redirected to the SITE_URL by default. You can modify your SITE_URL or add additional redirect URLs in your project. + /// When the user confirms their email address, they are redirected to the SITE_URL by default. You can modify your SITE_URL or + /// add additional redirect URLs in your project. /// If signUp() is called for an existing confirmed user: /// - If Confirm email is enabled in your project, an obfuscated/fake user object is returned. /// - If Confirm email is disabled, the error message, User already registered is returned. - /// To fetch the currently logged-in user, refer to . + /// To fetch the currently logged-in user, refer to + /// User + /// . /// /// /// @@ -148,7 +208,9 @@ public Client(ClientOptions? options = null) /// Signs up a user /// /// - /// By default, the user needs to verify their email address before logging in. To turn this off, disable Confirm email in your project. + /// Calling this method will log out the current user session (if any). + /// + /// By default, the user needs to verify their email address before logging in. To turn this off, disable confirm email in your project. /// Confirm email determines if users need to confirm their email address after signing up. /// - If Confirm email is enabled, a user is returned but session is null. /// - If Confirm email is disabled, both a user and a session are returned. @@ -165,58 +227,35 @@ public Client(ClientOptions? options = null) /// public async Task SignUp(SignUpType type, string identifier, string password, SignUpOptions? options = null) { - await DestroySession(); + DestroySession(); - try + var session = type switch { - Session? session = null; - switch (type) - { - case SignUpType.Email: - session = await _api.SignUpWithEmail(identifier, password, options); - break; - case SignUpType.Phone: - session = await _api.SignUpWithPhone(identifier, password, options); - break; - } - - if (session?.User?.ConfirmedAt != null || (session?.User != null && Options.AllowUnconfirmedUserSessions)) - { - await PersistSession(session); - - StateChanged?.Invoke(this, new ClientStateChanged(AuthState.SignedIn)); - - return CurrentSession; - } + SignUpType.Email => await _api.SignUpWithEmail(identifier, password, options), + SignUpType.Phone => await _api.SignUpWithPhone(identifier, password, options), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; - return session; - } - catch (RequestException ex) + if (session?.User?.ConfirmedAt != null || session?.User != null && Options.AllowUnconfirmedUserSessions) { - throw ExceptionHandler.Parse(ex); + UpdateSession(session); + NotifyAuthStateChange(SignedIn); + return CurrentSession; } + + return session; } /// - /// Sends a Magic email login link to the specified email. + /// Sends a magic link login email to the specified email. /// /// /// - /// public async Task SignIn(string email, SignInOptions? options = null) { - await DestroySession(); - - try - { - await _api.SendMagicLinkEmail(email, options); - return true; - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } + await _api.SendMagicLinkEmail(email, options); + return true; } /// @@ -228,24 +267,19 @@ public async Task SignIn(string email, SignInOptions? options = null) /// Provided from External Library /// Provided from External Library /// Provided from External Library - /// - /// + /// Calling this method will eliminate the current session (if any). + /// + /// InvalidProviderException + /// public async Task SignInWithIdToken(Provider provider, string idToken, string? nonce = null, string? captchaToken = null) { - try - { - await DestroySession(); - var result = await _api.SignInWithIdToken(provider, idToken, nonce, captchaToken); + DestroySession(); + var result = await _api.SignInWithIdToken(provider, idToken, nonce, captchaToken); - if (result != null) - await PersistSession(result); + if (result != null) + NotifyAuthStateChange(SignedIn); - return result; - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } + return result; } /// @@ -263,19 +297,13 @@ public async Task SignIn(string email, SignInOptions? options = null) /// if you are using phone sign in with the 'whatsapp' channel. The whatsapp /// channel is not supported on other providers at this time. /// + /// Calling this method will wipe out the current session (if any) /// /// public async Task SignInWithOtp(SignInWithPasswordlessEmailOptions options) { - try - { - await DestroySession(); - return await _api.SignInWithOtp(options); - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } + DestroySession(); + return await _api.SignInWithOtp(options); } /// @@ -293,19 +321,13 @@ public async Task SignInWithOtp(SignInWithPasswordlessE /// if you are using phone sign in with the 'whatsapp' channel. The whatsapp /// channel is not supported on other providers at this time. /// + /// Calling this method will wipe out the current session (if any) /// /// public async Task SignInWithOtp(SignInWithPasswordlessPhoneOptions options) { - try - { - await DestroySession(); - return await _api.SignInWithOtp(options); - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } + DestroySession(); + return await _api.SignInWithOtp(options); } /// @@ -316,7 +338,6 @@ public async Task SignInWithOtp(SignInWithPasswordlessP /// public Task SendMagicLink(string email, SignInOptions? options = null) => SignIn(email, options); - /// /// Signs in a User. /// @@ -343,47 +364,38 @@ public async Task SignInWithOtp(SignInWithPasswordlessP /// public async Task SignIn(SignInType type, string identifierOrToken, string? password = null, string? scopes = null) { - await DestroySession(); - - try + Session? session; + switch (type) { - Session? session = null; - switch (type) - { - case SignInType.Email: - session = await _api.SignInWithEmail(identifierOrToken, password!); - break; - case SignInType.Phone: + case SignInType.Email: + session = await _api.SignInWithEmail(identifierOrToken, password!); + UpdateSession(session); + break; + case SignInType.Phone: if (string.IsNullOrEmpty(password)) - { - await _api.SendMobileOTP(identifierOrToken); - return null; - } - - session = await _api.SignInWithPhone(identifierOrToken, password!); - break; - case SignInType.RefreshToken: - CurrentSession = new Session(); - CurrentSession.RefreshToken = identifierOrToken; - - await RefreshToken(); - - return CurrentSession; - } + { + await _api.SendMobileOTP(identifierOrToken); + return null; + } - if (session?.User?.ConfirmedAt != null || (session?.User != null && Options.AllowUnconfirmedUserSessions)) - { - await PersistSession(session); - StateChanged?.Invoke(this, new ClientStateChanged(AuthState.SignedIn)); + session = await _api.SignInWithPhone(identifierOrToken, password!); + UpdateSession(session); + break; + case SignInType.RefreshToken: + CurrentSession = new Session(); + CurrentSession.RefreshToken = identifierOrToken; + await RefreshToken(); return CurrentSession; - } - - return null; + default: throw new ArgumentOutOfRangeException(nameof(type), type, null); } - catch (RequestException ex) + + if (session?.User?.ConfirmedAt != null || session?.User != null && Options.AllowUnconfirmedUserSessions) { - throw ExceptionHandler.Parse(ex); + NotifyAuthStateChange(SignedIn); + return CurrentSession; } + + return null; } /// @@ -395,12 +407,12 @@ public async Task SignInWithOtp(SignInWithPasswordlessP /// /// /// - public async Task SignIn(Provider provider, SignInOptions? options = null) + public Task SignIn(Provider provider, SignInOptions? options = null) { - await DestroySession(); + DestroySession(); var providerUri = _api.GetUriForProvider(provider, options); - return providerUri; + return Task.FromResult(providerUri); } /// @@ -412,25 +424,18 @@ public async Task SignIn(Provider provider, SignInOptions? op /// public async Task VerifyOTP(string phone, string token, MobileOtpType type = MobileOtpType.SMS) { - try - { - await DestroySession(); - - var session = await _api.VerifyMobileOTP(phone, token, type); + DestroySession(); - if (session?.AccessToken != null) - { - await PersistSession(session); - StateChanged?.Invoke(this, new ClientStateChanged(AuthState.SignedIn)); - return session; - } + var session = await _api.VerifyMobileOTP(phone, token, type); - return null; - } - catch (RequestException ex) + if (session?.AccessToken != null) { - throw ExceptionHandler.Parse(ex); + UpdateSession(session); + NotifyAuthStateChange(SignedIn); + return session; } + + return null; } /// @@ -442,25 +447,18 @@ public async Task SignIn(Provider provider, SignInOptions? op /// public async Task VerifyOTP(string email, string token, EmailOtpType type = EmailOtpType.MagicLink) { - try - { - await DestroySession(); - - var session = await _api.VerifyEmailOTP(email, token, type); + DestroySession(); - if (session?.AccessToken != null) - { - await PersistSession(session); - StateChanged?.Invoke(this, new ClientStateChanged(AuthState.SignedIn)); - return session; - } + var session = await _api.VerifyEmailOTP(email, token, type); - return null; - } - catch (RequestException ex) + if (session?.AccessToken != null) { - throw ExceptionHandler.Parse(ex); + UpdateSession(session); + NotifyAuthStateChange(SignedIn); + return session; } + + return null; } /// @@ -469,17 +467,11 @@ public async Task SignIn(Provider provider, SignInOptions? op /// public async Task SignOut() { - if (CurrentSession != null) - { - if (CurrentSession.AccessToken != null) - await _api.SignOut(CurrentSession.AccessToken); - - _refreshTimer?.Dispose(); - - await DestroySession(); - - StateChanged?.Invoke(this, new ClientStateChanged(AuthState.SignedOut)); - } + if (CurrentSession?.AccessToken != null) + await _api.SignOut(CurrentSession.AccessToken); + _refreshTimer?.Dispose(); + UpdateSession(null); + NotifyAuthStateChange(SignedOut); } /// @@ -492,20 +484,11 @@ public async Task SignOut() if (CurrentSession == null || string.IsNullOrEmpty(CurrentSession.AccessToken)) throw new Exception("Not Logged in."); - try - { - var result = await _api.UpdateUser(CurrentSession.AccessToken!, attributes); - - CurrentUser = result; + var result = await _api.UpdateUser(CurrentSession.AccessToken!, attributes); + CurrentSession.User = result; + NotifyAuthStateChange(UserUpdated); - StateChanged?.Invoke(this, new ClientStateChanged(AuthState.UserUpdated)); - - return result; - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } + return result; } /// @@ -516,16 +499,9 @@ public async Task SignOut() /// public async Task InviteUserByEmail(string email, string jwt) { - try - { - var response = await _api.InviteUserByEmail(email, jwt); - response.ResponseMessage?.EnsureSuccessStatusCode(); - return true; - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } + var response = await _api.InviteUserByEmail(email, jwt); + response.ResponseMessage?.EnsureSuccessStatusCode(); + return true; } /// @@ -536,16 +512,9 @@ public async Task InviteUserByEmail(string email, string jwt) /// public async Task DeleteUser(string uid, string jwt) { - try - { - var result = await _api.DeleteUser(uid, jwt); - result.ResponseMessage?.EnsureSuccessStatusCode(); - return true; - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } + var result = await _api.DeleteUser(uid, jwt); + result.ResponseMessage?.EnsureSuccessStatusCode(); + return true; } /// @@ -553,23 +522,13 @@ public async Task DeleteUser(string uid, string jwt) /// /// A valid JWT. Must be a full-access API key (e.g. service_role key). /// A string for example part of the email - /// Snake case string of the given key, currently only created_at is suppported + /// Snake case string of the given key, currently only created_at is supported /// asc or desc, if null desc is used /// page to show for pagination /// items per page for pagination /// - public async Task?> ListUsers(string jwt, string? filter = null, string? sortBy = null, SortOrder sortOrder = SortOrder.Descending, int? page = null, - int? perPage = null) - { - try - { - return await _api.ListUsers(jwt, filter, sortBy, sortOrder, page, perPage); - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } + public Task?> ListUsers(string jwt, string? filter = null, string? sortBy = null, SortOrder sortOrder = SortOrder.Descending, int? page = null, + int? perPage = null) => _api.ListUsers(jwt, filter, sortBy, sortOrder, page, perPage); /// /// Get User details by Id @@ -577,34 +536,14 @@ public async Task DeleteUser(string uid, string jwt) /// A valid JWT. Must be a full-access API key (e.g. service_role key). /// /// - public async Task GetUserById(string jwt, string userId) - { - try - { - return await _api.GetUserById(jwt, userId); - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } + public Task GetUserById(string jwt, string userId) => _api.GetUserById(jwt, userId); /// /// Get User details by JWT. Can be used to validate a JWT. /// /// A valid JWT. Must be a JWT that originates from a user. /// - public async Task GetUser(string jwt) - { - try - { - return await _api.GetUser(jwt); - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } + public Task GetUser(string jwt) => _api.GetUser(jwt); /// /// Create a user (as a service_role) @@ -616,33 +555,21 @@ public async Task DeleteUser(string uid, string jwt) /// public Task CreateUser(string jwt, string email, string password, AdminUserAttributes? attributes = null) { - if (attributes == null) - { - attributes = new AdminUserAttributes(); - } + attributes ??= new AdminUserAttributes(); attributes.Email = email; attributes.Password = password; return CreateUser(jwt, attributes); } + /// /// Create a user (as a service_role) /// /// A valid JWT. Must be a full-access API key (e.g. service_role key). /// /// - public async Task CreateUser(string jwt, AdminUserAttributes attributes) - { - try - { - return await _api.CreateUser(jwt, attributes); - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } + public Task CreateUser(string jwt, AdminUserAttributes attributes) => _api.CreateUser(jwt, attributes); /// /// Update user by Id @@ -651,17 +578,7 @@ public async Task DeleteUser(string uid, string jwt) /// /// /// - public async Task UpdateUserById(string jwt, string userId, AdminUserAttributes userData) - { - try - { - return await _api.UpdateUserById(jwt, userId, userData); - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } + public Task UpdateUserById(string jwt, string userId, AdminUserAttributes userData) => _api.UpdateUserById(jwt, userId, userData); /// /// Sends a reset request to an email address. @@ -671,16 +588,9 @@ public async Task DeleteUser(string uid, string jwt) /// public async Task ResetPasswordForEmail(string email) { - try - { - var result = await _api.ResetPasswordForEmail(email); - result.ResponseMessage?.EnsureSuccessStatusCode(); - return true; - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } + var result = await _api.ResetPasswordForEmail(email); + result.ResponseMessage?.EnsureSuccessStatusCode(); + return true; } /// @@ -695,7 +605,7 @@ public async Task ResetPasswordForEmail(string email) await RefreshToken(); var user = await _api.GetUser(CurrentSession.AccessToken!); - CurrentUser = user; + CurrentSession.User = user; return CurrentSession; } @@ -707,13 +617,13 @@ public async Task ResetPasswordForEmail(string email) /// Session. public Session SetAuth(string accessToken) { - if (CurrentSession == null) CurrentSession = new Session(); + CurrentSession ??= new Session(); CurrentSession.AccessToken = accessToken; CurrentSession.TokenType = "bearer"; CurrentSession.User = CurrentUser; - StateChanged?.Invoke(this, new ClientStateChanged(AuthState.TokenRefreshed)); + NotifyAuthStateChange(TokenRefreshed); return CurrentSession; } @@ -765,29 +675,30 @@ public Session SetAuth(string accessToken) if (storeSession) { - await PersistSession(session); - StateChanged?.Invoke(this, new ClientStateChanged(AuthState.SignedIn)); + UpdateSession(session); + NotifyAuthStateChange(SignedIn); if (query.Get("type") == "recovery") - StateChanged?.Invoke(this, new ClientStateChanged(AuthState.PasswordRecovery)); + NotifyAuthStateChange(PasswordRecovery); } return session; } /// - /// Retrieves the Session by calling - sets internal state and timers. + /// Retrieves the Session by calling + /// SessionRetriever + /// + /// - sets internal state and timers. /// /// public async Task RetrieveSessionAsync() { - if (SessionRetriever == null) return null; - - var session = await SessionRetriever.Invoke(); + var session = CurrentSession; if (session != null && session.ExpiresAt() < DateTime.Now) { - if (AutoRefreshToken && session.RefreshToken != null) + if (Options.AutoRefreshToken && session.RefreshToken != null) { try { @@ -796,33 +707,26 @@ public Session SetAuth(string accessToken) } catch { - await DestroySession(); + DestroySession(); return null; } } - else - { - await DestroySession(); - return null; - } + DestroySession(); + return null; } - else if (session == null || session.User == null) + + if (session?.User == null) { _debugNotification?.Log("Stored Session is missing data."); - await DestroySession(); + DestroySession(); return null; } - else - { - CurrentSession = session; - CurrentUser = session.User; - StateChanged?.Invoke(this, new ClientStateChanged(AuthState.SignedIn)); - - InitRefreshTimer(); + CurrentSession = session; - return CurrentSession; - } + NotifyAuthStateChange(SignedIn); + InitRefreshTimer(); + return CurrentSession; } /// @@ -836,42 +740,64 @@ public Session SetAuth(string accessToken) if (result != null) { - await PersistSession(result); - StateChanged?.Invoke(this, new ClientStateChanged(AuthState.SignedIn)); + UpdateSession(result); + NotifyAuthStateChange(SignedIn); return CurrentSession; } return null; } + public Func>? GetHeaders + { + get => _api.GetHeaders; + set => throw new ArgumentException(); + } + /// - /// Persists a Session in memory and calls (if specified) + /// Add a listener to get errors that occur outside of a typical Exception flow. + /// In particular, this is used to get errors and messages from the background thread + /// that automatically manages refreshing the user's token. + /// + /// + public void AddDebugListener(Action listener) + { + _debugNotification ??= new DebugNotification(); + _debugNotification.AddDebugListener(listener); + } + + /// + /// Saves the session /// /// - private async Task PersistSession(Session session) + private void UpdateSession(Session? session) { + if (session == null) + { + CurrentSession = null; + NotifyAuthStateChange(SignedOut); + return; + } + + var dirty = CurrentSession != session; + CurrentSession = session; - CurrentUser = session.User; var expiration = session.ExpiresIn; - if (AutoRefreshToken && expiration != default) + if (Options.AutoRefreshToken && expiration != default) InitRefreshTimer(); - if (ShouldPersistSession && SessionPersistor != null) - await SessionPersistor.Invoke(session); + if (dirty) + NotifyAuthStateChange(UserUpdated); } /// - /// Persists a Session in memory and calls (if specified) + /// Clears the session /// - private async Task DestroySession() + private void DestroySession() { - CurrentSession = null; - CurrentUser = null; - - if (ShouldPersistSession && SessionDestroyer != null) - await SessionDestroyer.Invoke(); + UpdateSession(null); } /// @@ -891,15 +817,10 @@ private async Task RefreshToken(string? refreshToken = null) throw new Exception("Could not refresh token from provided session."); CurrentSession = result; - CurrentUser = result.User; - - if (ShouldPersistSession && SessionPersistor != null) - await SessionPersistor.Invoke(result); - StateChanged?.Invoke(this, new ClientStateChanged(AuthState.TokenRefreshed)); - StateChanged?.Invoke(this, new ClientStateChanged(AuthState.SignedIn)); + NotifyAuthStateChange(TokenRefreshed); - if (AutoRefreshToken && CurrentSession.ExpiresIn != default) + if (Options.AutoRefreshToken && CurrentSession.ExpiresIn != default) InitRefreshTimer(); } @@ -907,15 +828,14 @@ private void InitRefreshTimer() { if (CurrentSession == null || CurrentSession.ExpiresIn == default) return; - if (_refreshTimer != null) - _refreshTimer.Dispose(); + _refreshTimer?.Dispose(); try { // Interval should be t - (1/5(n)) (i.e. if session time (t) 3600s, attempt refresh at 2880s or 720s (1/5) seconds before expiration) - int interval = (int)Math.Floor(CurrentSession.ExpiresIn * 4.0f / 5.0f); - int timeoutSeconds = Convert.ToInt32((CurrentSession.CreatedAt.AddSeconds(interval) - DateTime.Now).TotalSeconds); - TimeSpan timeout = TimeSpan.FromSeconds(timeoutSeconds); + var interval = (int)Math.Floor(CurrentSession.ExpiresIn * 4.0f / 5.0f); + var timeoutSeconds = Convert.ToInt32((CurrentSession.CreatedAt.AddSeconds(interval) - DateTime.Now).TotalSeconds); + var timeout = TimeSpan.FromSeconds(timeoutSeconds); _refreshTimer = new Timer(HandleRefreshTimerTick, null, timeout, Timeout.InfiniteTimeSpan); } @@ -943,21 +863,13 @@ private async void HandleRefreshTimerTick(object _) catch (Exception ex) { _debugNotification?.Log(ex.Message, ex); - StateChanged?.Invoke(this, new ClientStateChanged(AuthState.SignedOut)); + NotifyAuthStateChange(SignedOut); } } - } - - /// - /// Class representing a state change on the . - /// - public class ClientStateChanged : EventArgs - { - public AuthState State { get; private set; } - - public ClientStateChanged(AuthState state) + public void LoadSession() { - State = state; + if (_sessionPersistence != null) + UpdateSession(_sessionPersistence.Persistence.LoadSession()); } } } diff --git a/Gotrue/ClientOptions.cs b/Gotrue/ClientOptions.cs index fd0c767..2356780 100644 --- a/Gotrue/ClientOptions.cs +++ b/Gotrue/ClientOptions.cs @@ -1,15 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; +using System.Collections.Generic; +using Supabase.Gotrue.Interfaces; using static Supabase.Gotrue.Constants; namespace Supabase.Gotrue { /// - /// Class represention options available to the . + /// Class representation options available to the . /// - public class ClientOptions - where TSession : Session + public class ClientOptions { /// /// Gotrue Endpoint @@ -19,33 +17,13 @@ public class ClientOptions /// /// Headers to be sent with subsequent requests. /// - public Dictionary Headers = new Dictionary(DEFAULT_HEADERS); + public Dictionary Headers = new Dictionary(); /// /// Should the Client automatically handle refreshing the User's Token? /// public bool AutoRefreshToken { get; set; } = true; - /// - /// Should the Client call , , and ? - /// - public bool PersistSession { get; set; } = true; - - /// - /// Function called to persist the session (probably on a filesystem or cookie) - /// - public Func> SessionPersistor = session => Task.FromResult(true); - - /// - /// Function to retrieve a session (probably from the filesystem or cookie) - /// - public Func> SessionRetriever = () => Task.FromResult(null); - - /// - /// Function to destroy a session. - /// - public Func> SessionDestroyer = () => Task.FromResult(true); - /// /// Very unlikely this flag needs to be changed except in very specific contexts. /// diff --git a/Gotrue/Constants.cs b/Gotrue/Constants.cs index f9d6cfb..3fcb67a 100644 --- a/Gotrue/Constants.cs +++ b/Gotrue/Constants.cs @@ -5,12 +5,11 @@ namespace Supabase.Gotrue { public static class Constants { - public static string GOTRUE_URL = "http://localhost:9999"; - public static string AUDIENCE = ""; - public static readonly Dictionary DEFAULT_HEADERS = new Dictionary(); - public static int EXPIRY_MARGIN = 60 * 1000; - public static string STORAGE_KEY = "supabase.auth.token"; - public static readonly Dictionary COOKIE_OPTIONS = new Dictionary{ + public const string GOTRUE_URL = "http://localhost:9999"; + public const string AUDIENCE = ""; + public const int EXPIRY_MARGIN = 60 * 1000; + public const string STORAGE_KEY = "supabase.auth.token"; + public static readonly Dictionary CookieOptions = new Dictionary{ { "name", "sb:token" }, { "lifetime", 60 * 60 * 8 }, { "domain", "" }, @@ -86,7 +85,7 @@ public enum Provider Twitter, [MapTo("workos")] WorkOS - }; + } /// /// States that the Auth Client will raise events for. @@ -98,7 +97,7 @@ public enum AuthState UserUpdated, PasswordRecovery, TokenRefreshed - }; + } /// /// Specifies the functionality expected from the `SignIn` method @@ -107,7 +106,7 @@ public enum SignInType { Email, Phone, - RefreshToken, + RefreshToken } /// diff --git a/Gotrue/ExceptionHandler.cs b/Gotrue/ExceptionHandler.cs deleted file mode 100644 index 34d19c8..0000000 --- a/Gotrue/ExceptionHandler.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Net; -using Supabase.Gotrue.Exceptions; - -namespace Supabase.Gotrue -{ - /// - /// Internal class for parsing Supabase specific exceptions. - /// - internal static class ExceptionHandler - { - internal static Exception Parse(RequestException ex) - { - switch (ex.Response.StatusCode) - { - case HttpStatusCode.Unauthorized: - return new UnauthorizedException(ex); - case HttpStatusCode.BadRequest: - return new BadRequestException(ex); - case HttpStatusCode.Forbidden: - return new ForbiddenException(ex); - } - return ex; - } - } -} diff --git a/Gotrue/Exceptions/BadRequestException.cs b/Gotrue/Exceptions/BadRequestException.cs deleted file mode 100644 index 59dc229..0000000 --- a/Gotrue/Exceptions/BadRequestException.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Net.Http; - -namespace Supabase.Gotrue.Exceptions -{ - public class BadRequestException : GotrueException - { - public HttpResponseMessage Response { get; private set; } - - public string? Content { get; private set; } - public BadRequestException(RequestException exception): base(exception.Error.Message, exception) - { - Response = exception.Response; - Content = exception.Error.Message; - } - } -} diff --git a/Gotrue/Exceptions/ExistingUserException.cs b/Gotrue/Exceptions/ExistingUserException.cs deleted file mode 100644 index 186a35d..0000000 --- a/Gotrue/Exceptions/ExistingUserException.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Net.Http; - -namespace Supabase.Gotrue.Exceptions -{ - public class ExistingUserException : GotrueException - { - public HttpResponseMessage Response { get; private set; } - public string? Content { get; private set; } - public ExistingUserException(RequestException exception) : base(exception.Error.Message, exception) - { - Response = exception.Response; - Content = exception.Error.Message; - } - } -} diff --git a/Gotrue/Exceptions/FailureReason.cs b/Gotrue/Exceptions/FailureReason.cs new file mode 100644 index 0000000..8201890 --- /dev/null +++ b/Gotrue/Exceptions/FailureReason.cs @@ -0,0 +1,39 @@ +using static Supabase.Gotrue.Exceptions.FailureHint.Reason; + +namespace Supabase.Gotrue.Exceptions +{ + /// + /// Maps Supabase server errors to hints based on the status code and the contents of the error message. + /// + public static class FailureHint + { + public enum Reason + { + Unknown, + UserBadPassword, + UserBadEmailAddress, + UserMissingInformation, + UserAlreadyRegistered, + InvalidRefreshToken, + AdminTokenRequired + } + + public static Reason DetectReason(GotrueException gte) + { + if (gte.Content == null) + return Unknown; + + return gte.StatusCode switch + { + 400 when gte.Content.Contains("User already registered") => UserAlreadyRegistered, + 400 when gte.Content.Contains("Invalid Refresh Token") => InvalidRefreshToken, + 401 when gte.Content.Contains("This endpoint requires a Bearer token") => AdminTokenRequired, + 422 when gte.Content.Contains("Password should be at least") => UserBadPassword, + 422 when gte.Content.Contains("Unable to validate email address") => UserBadEmailAddress, + 422 when gte.Content.Contains("provide your email or phone number") => UserMissingInformation, + _ => Unknown + }; + + } + } +} diff --git a/Gotrue/Exceptions/ForbiddenException.cs b/Gotrue/Exceptions/ForbiddenException.cs deleted file mode 100644 index a1fac07..0000000 --- a/Gotrue/Exceptions/ForbiddenException.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Net.Http; - -namespace Supabase.Gotrue.Exceptions -{ - public class ForbiddenException : GotrueException - { - public HttpResponseMessage Response { get; private set; } - public string? Content { get; private set; } - public ForbiddenException(RequestException exception) : base(exception.Error.Message, exception) - { - Response = exception.Response; - Content = exception.Error.Message; - } - } -} diff --git a/Gotrue/Exceptions/GotrueException.cs b/Gotrue/Exceptions/GotrueException.cs index 20d0a42..aa970bb 100644 --- a/Gotrue/Exceptions/GotrueException.cs +++ b/Gotrue/Exceptions/GotrueException.cs @@ -1,10 +1,26 @@ using System; +using System.Net.Http; namespace Supabase.Gotrue.Exceptions { + /// + /// Errors from the GoTrue server are wrapped by this exception + /// public class GotrueException : Exception { public GotrueException(string? message) : base(message) { } public GotrueException(string? message, Exception? innerException) : base(message, innerException) { } + + public HttpResponseMessage? Response { get; internal set; } + + public string? Content { get; internal set; } + + public int StatusCode { get; internal set; } + public void AddReason() + { + Reason = FailureHint.DetectReason(this); + //Debug.WriteLine(Content); + } + public FailureHint.Reason Reason { get; private set; } } } diff --git a/Gotrue/Exceptions/InvalidEmailOrPasswordException.cs b/Gotrue/Exceptions/InvalidEmailOrPasswordException.cs deleted file mode 100644 index 6d6e264..0000000 --- a/Gotrue/Exceptions/InvalidEmailOrPasswordException.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Net.Http; - -namespace Supabase.Gotrue.Exceptions -{ - public class InvalidEmailOrPasswordException : GotrueException - { - public HttpResponseMessage Response { get; private set; } - public string? Content { get; private set; } - public InvalidEmailOrPasswordException(RequestException exception) : base(exception.Error.Message, exception) - { - Response = exception.Response; - Content = exception.Error.Message; - } - } -} diff --git a/Gotrue/Exceptions/InvalidProviderException.cs b/Gotrue/Exceptions/InvalidProviderException.cs deleted file mode 100644 index 4e29664..0000000 --- a/Gotrue/Exceptions/InvalidProviderException.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Supabase.Gotrue.Exceptions -{ - public class InvalidProviderException : GotrueException - { - public InvalidProviderException(string message) : base(message) { } - } -} - diff --git a/Gotrue/Exceptions/RequestException.cs b/Gotrue/Exceptions/RequestException.cs deleted file mode 100644 index 27b86c7..0000000 --- a/Gotrue/Exceptions/RequestException.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Net.Http; -using Supabase.Gotrue.Exceptions; -using Supabase.Gotrue.Responses; - -namespace Supabase.Gotrue -{ - public class RequestException : GotrueException - { - public HttpResponseMessage Response { get; private set; } - public ErrorResponse Error { get; private set; } - - public RequestException(HttpResponseMessage response, ErrorResponse error) : base(error.Message) - { - Response = response; - Error = error; - } - } -} diff --git a/Gotrue/Exceptions/UnauthorizedException.cs b/Gotrue/Exceptions/UnauthorizedException.cs deleted file mode 100644 index 0d5cab1..0000000 --- a/Gotrue/Exceptions/UnauthorizedException.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Net.Http; - -namespace Supabase.Gotrue.Exceptions -{ - public class UnauthorizedException : GotrueException - { - public HttpResponseMessage Response { get; private set; } - - public string? Content { get; private set; } - public UnauthorizedException(RequestException exception) : base(exception.Error.Message, exception) - { - Response = exception.Response; - Content = exception.Error.Message; - } - } -} diff --git a/Gotrue/Gotrue.csproj b/Gotrue/Gotrue.csproj index 8cc99da..bfe6a35 100644 --- a/Gotrue/Gotrue.csproj +++ b/Gotrue/Gotrue.csproj @@ -21,7 +21,7 @@ enable - 8.0 + latest CS8600;CS8602;CS8603 diff --git a/Gotrue/Helpers.cs b/Gotrue/Helpers.cs index 7fa3187..6e2cefd 100644 --- a/Gotrue/Helpers.cs +++ b/Gotrue/Helpers.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using System.Web; using Newtonsoft.Json; +using Supabase.Gotrue.Exceptions; using Supabase.Gotrue.Responses; namespace Supabase.Gotrue @@ -22,10 +23,11 @@ public static class Helpers /// public static string GenerateNonce() { + // ReSharper disable once StringLiteralTypo const string chars = "abcdefghijklmnopqrstuvwxyz123456789"; var random = new Random(); var nonce = new char[128]; - for (int i = 0; i < nonce.Length; i++) + for (var i = 0; i < nonce.Length; i++) { nonce[i] = chars[random.Next(chars.Length)]; } @@ -59,12 +61,12 @@ public static string GeneratePKCENonceVerifier(string codeVerifier) /// public static string GenerateSHA256NonceFromRawNonce(string rawNonce) { - SHA256Managed sha = new SHA256Managed(); - byte[] utf8RawNonce = Encoding.UTF8.GetBytes(rawNonce); - byte[] hash = sha.ComputeHash(utf8RawNonce); + var sha = new SHA256Managed(); + var utf8RawNonce = Encoding.UTF8.GetBytes(rawNonce); + var hash = sha.ComputeHash(utf8RawNonce); - string result = string.Empty; - foreach (byte t in hash) + var result = string.Empty; + foreach (var t in hash) result += t.ToString("x2"); return result; @@ -89,7 +91,7 @@ internal static Uri AddQueryParams(string url, Dictionary data) return builder.Uri; } - private static readonly HttpClient client = new HttpClient(); + private static readonly HttpClient Client = new HttpClient(); /// /// Helper to make a request using the defined parameters to an API Endpoint and coerce into a model. @@ -132,37 +134,35 @@ internal static async Task MakeRequest(HttpMethod method, string u builder.Query = query.ToString(); - using (var requestMessage = new HttpRequestMessage(method, builder.Uri)) + using var requestMessage = new HttpRequestMessage(method, builder.Uri); + if (data != null && method != HttpMethod.Get) { + requestMessage.Content = new StringContent(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json"); + } - if (data != null && method != HttpMethod.Get) + if (headers != null) + { + foreach (var kvp in headers) { - requestMessage.Content = new StringContent(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json"); + requestMessage.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); } + } - if (headers != null) - { - foreach (var kvp in headers) - { - requestMessage.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); - } - } + var response = await Client.SendAsync(requestMessage); + var content = await response.Content.ReadAsStringAsync(); - var response = await client.SendAsync(requestMessage); - var content = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + { + var e = new GotrueException("Request Failed"); + e.Content = content; + e.Response = response; + e.StatusCode = (int)response.StatusCode; + e.AddReason(); + throw e; + } - if (!response.IsSuccessStatusCode) - { - var obj = new ErrorResponse - { - Content = content, - Message = content - }; - throw new RequestException(response, obj); - } + return new BaseResponse { Content = content, ResponseMessage = response }; - return new BaseResponse { Content = content, ResponseMessage = response }; - } } } } diff --git a/Gotrue/Interfaces/IGotrueApi.cs b/Gotrue/Interfaces/IGotrueApi.cs index 9540bc3..0d3f8a7 100644 --- a/Gotrue/Interfaces/IGotrueApi.cs +++ b/Gotrue/Interfaces/IGotrueApi.cs @@ -34,5 +34,8 @@ public interface IGotrueApi : IGettableHeaders ProviderAuthState GetUriForProvider(Provider provider, SignInOptions? options = null); Task ExchangeCodeForSession(string codeVerifier, string authCode); + + Task Settings(); } + } \ No newline at end of file diff --git a/Gotrue/Interfaces/IGotrueClient.cs b/Gotrue/Interfaces/IGotrueClient.cs index fc687a8..a0ac417 100644 --- a/Gotrue/Interfaces/IGotrueClient.cs +++ b/Gotrue/Interfaces/IGotrueClient.cs @@ -12,7 +12,15 @@ public interface IGotrueClient : IGettableHeaders TSession? CurrentSession { get; } TUser? CurrentUser { get; } - event EventHandler? StateChanged; + delegate void AuthEventHandler(IGotrueClient sender, AuthState stateChanged); + + public void SetPersistence(IGotrueSessionPersistence persistence); + + public void AddStateChangedListener(AuthEventHandler authEventHandler); + public void RemoveStateChangedListener(AuthEventHandler authEventHandler); + public void ClearStateChangedListeners(); + public void NotifyAuthStateChange(AuthState stateChanged); + Task CreateUser(string jwt, AdminUserAttributes attributes); Task CreateUser(string jwt, string email, string password, AdminUserAttributes? attributes = null); Task DeleteUser(string uid, string jwt); diff --git a/Gotrue/Interfaces/IGotruePersistenceListener.cs b/Gotrue/Interfaces/IGotruePersistenceListener.cs new file mode 100644 index 0000000..95faf63 --- /dev/null +++ b/Gotrue/Interfaces/IGotruePersistenceListener.cs @@ -0,0 +1,12 @@ +namespace Supabase.Gotrue.Interfaces +{ + /// + /// Interface for a session persistence auth state handler. + /// + public interface IGotruePersistenceListener where TSession : Session + { + IGotrueSessionPersistence Persistence { get; } + + public void EventHandler(IGotrueClient sender, Constants.AuthState stateChanged); + } +} \ No newline at end of file diff --git a/Gotrue/Interfaces/IGotrueSessionPersistence.cs b/Gotrue/Interfaces/IGotrueSessionPersistence.cs new file mode 100644 index 0000000..e9126a1 --- /dev/null +++ b/Gotrue/Interfaces/IGotrueSessionPersistence.cs @@ -0,0 +1,15 @@ +namespace Supabase.Gotrue.Interfaces +{ + /// + /// Interface for session persistence. As a reminder, make sure you handle exceptions and + /// other error conditions in your implementation. + /// + public interface IGotrueSessionPersistence where TSession : Session + { + public void SaveSession(TSession session); + + public void DestroySession(); + + public TSession LoadSession(); + } +} diff --git a/Gotrue/PersistenceListener.cs b/Gotrue/PersistenceListener.cs new file mode 100644 index 0000000..6b0bff1 --- /dev/null +++ b/Gotrue/PersistenceListener.cs @@ -0,0 +1,54 @@ +using System; +using Supabase.Gotrue.Interfaces; + +namespace Supabase.Gotrue +{ + public class PersistenceListener : IGotruePersistenceListener + { + public PersistenceListener(IGotrueSessionPersistence persistence) + { + Persistence = persistence; + } + + public IGotrueSessionPersistence Persistence { get; } + + public void EventHandler(IGotrueClient sender, Constants.AuthState stateChanged) + { + switch (stateChanged) + { + case Constants.AuthState.SignedIn: + if (sender == null) + throw new ArgumentException("Tried to save a null session (1)"); + if (sender.CurrentSession == null) + throw new ArgumentException("Tried to save a null session (2)"); + + Persistence.SaveSession(sender.CurrentSession); + break; + case Constants.AuthState.SignedOut: + Persistence.DestroySession(); + break; + case Constants.AuthState.UserUpdated: + if (sender == null) + throw new ArgumentException("Tried to save a null session (1)"); + if (sender.CurrentSession == null) + throw new ArgumentException("Tried to save a null session (2)"); + + Persistence.SaveSession(sender.CurrentSession); + break; + case Constants.AuthState.PasswordRecovery: break; + case Constants.AuthState.TokenRefreshed: + if (sender.CurrentSession == null) + { + // If token refresh results in a null session, log out. + EventHandler(sender, Constants.AuthState.SignedOut); + } + else + { + Persistence.SaveSession(sender.CurrentSession); + } + break; + default: throw new ArgumentOutOfRangeException(nameof(stateChanged), stateChanged, null); + } + } + } +} diff --git a/Gotrue/Responses/ErrorResponse.cs b/Gotrue/Responses/ErrorResponse.cs deleted file mode 100644 index 9516ab9..0000000 --- a/Gotrue/Responses/ErrorResponse.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Supabase.Gotrue.Responses -{ - /// - /// A representation of Postgrest's API error response. - /// - public class ErrorResponse : BaseResponse - { - public string? Message { get; set; } - } -} diff --git a/Gotrue/Settings.cs b/Gotrue/Settings.cs new file mode 100644 index 0000000..47a6e29 --- /dev/null +++ b/Gotrue/Settings.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Supabase.Gotrue +{ + public class Settings + { + [JsonProperty("disable_signup")] + public bool? DisableSignup { get; set; } + + [JsonProperty("mailer_autoconfirm")] + public bool? MailerAutoConfirm { get; set; } + + [JsonProperty("phone_autoconfirm")] + public bool? PhoneAutoConfirm { get; set; } + + [JsonProperty("sms_provider")] + public string? SmsProvider { get; set; } + + [JsonProperty("external")] + public Dictionary? Services { get; set; } + + [JsonProperty("external_labels")] + public Dictionary? Labels { get; set; } + } +} diff --git a/Gotrue/StatelessClient.cs b/Gotrue/StatelessClient.cs index 2ef1ba1..e8b77ee 100644 --- a/Gotrue/StatelessClient.cs +++ b/Gotrue/StatelessClient.cs @@ -7,536 +7,431 @@ namespace Supabase.Gotrue { - /// - /// A Stateless Gotrue Client - /// - /// - /// var options = new StatelessClientOptions { Url = "https://mygotrueurl.com" }; - /// var user = await client.SignIn("user@email.com", "fancyPassword", options); - /// - public class StatelessClient : IGotrueStatelessClient - { - - public IGotrueApi GetApi(StatelessClientOptions options) => new Api(options.Url, options.Headers); - - /// - /// Signs up a user by email address - /// - /// - /// - /// - /// Object containing redirectTo and optional user metadata (data) - /// - public Task SignUp(string email, string password, StatelessClientOptions options, SignUpOptions? signUpOptions = null) => SignUp(SignUpType.Email, email, password, options, signUpOptions); - - /// - /// Signs up a user - /// - /// Type of signup - /// Phone or Email - /// - /// - /// Object containing redirectTo and optional user metadata (data) - /// - public async Task SignUp(SignUpType type, string identifier, string password, StatelessClientOptions options, SignUpOptions? signUpOptions = null) - { - try - { - var api = GetApi(options); - Session? session = null; - switch (type) - { - case SignUpType.Email: - session = await api.SignUpWithEmail(identifier, password, signUpOptions); - break; - case SignUpType.Phone: - session = await api.SignUpWithPhone(identifier, password, signUpOptions); - break; - } - - if (session?.User?.ConfirmedAt != null || (session?.User != null && options.AllowUnconfirmedUserSessions)) - { - return session; - } - - return null; - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } - - - /// - /// Sends a Magic email login link to the specified email. - /// - /// - /// - /// - /// - public async Task SignIn(string email, StatelessClientOptions options, SignInOptions? signInOptions = null) - { - try - { - await GetApi(options).SendMagicLinkEmail(email, signInOptions); - return true; - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } - - /// - /// Sends a Magic email login link to the specified email. - /// - /// - /// - /// - /// - public Task SendMagicLink(string email, StatelessClientOptions options, SignInOptions? signInOptions = null) => SignIn(email, options, signInOptions); - - /// - /// Signs in a User. - /// - /// - /// - /// - /// - public Task SignIn(string email, string password, StatelessClientOptions options) => SignIn(SignInType.Email, email, password, options); - - /// - /// Log in an existing user, or login via a third-party provider. - /// - /// Type of Credentials being passed - /// An email, phone, or RefreshToken - /// Password to account (optional if `RefreshToken`) - /// - /// - public async Task SignIn(SignInType type, string identifierOrToken, string? password = null, StatelessClientOptions? options = null) - { - try - { - if (options == null) - options = new StatelessClientOptions(); - - var api = GetApi(options); - Session? session = null; - switch (type) - { - case SignInType.Email: - session = await api.SignInWithEmail(identifierOrToken, password!); - break; - case SignInType.Phone: - if (string.IsNullOrEmpty(password)) - { - var response = await api.SendMobileOTP(identifierOrToken); - return null; - } - - session = await api.SignInWithPhone(identifierOrToken, password!); - break; - case SignInType.RefreshToken: - session = await RefreshToken(identifierOrToken, options); - break; - } - - if (session?.User?.ConfirmedAt != null || (session?.User != null && options.AllowUnconfirmedUserSessions)) - { - return session; - } - - return null; - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } - - /// - /// Retrieves a Url to redirect to for signing in with a . - /// - /// This method will need to be combined with when the - /// Application receives the Oauth Callback. - /// - /// - /// var client = Supabase.Gotrue.Client.Initialize(options); - /// var url = client.SignIn(Provider.Github); - /// - /// // Do Redirect User - /// - /// // Example code - /// Application.HasRecievedOauth += async (uri) => { - /// var session = await client.GetSessionFromUri(uri, true); - /// } - /// - /// - /// - /// - /// - public ProviderAuthState SignIn(Provider provider, StatelessClientOptions options, SignInOptions? signInOptions = null) => GetApi(options).GetUriForProvider(provider, signInOptions); - - /// - /// Logout a User - /// This will revoke all refresh tokens for the user. - /// JWT tokens will still be valid for stateless auth until they expire. - /// - /// - /// - /// - public async Task SignOut(string jwt, StatelessClientOptions options) - { - try - { - var result = await GetApi(options).SignOut(jwt); - result.ResponseMessage?.EnsureSuccessStatusCode(); - return true; - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } - - /// - /// Log in a user given a User supplied OTP received via mobile. - /// - /// The user's phone number. - /// Token sent to the user's phone. - /// - /// - /// - public async Task VerifyOTP(string phone, string token, StatelessClientOptions options, MobileOtpType type = MobileOtpType.SMS) - { - try - { - var session = await GetApi(options).VerifyMobileOTP(phone, token, type); - - if (session?.AccessToken != null) - { - return session; - } - - return null; - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } - - /// - /// Log in a user give a user supplied OTP received via email. - /// - /// - /// - /// - /// - /// - public async Task VerifyOTP(string email, string token, StatelessClientOptions options, EmailOtpType type = EmailOtpType.MagicLink) - { - try - { - var session = await GetApi(options).VerifyEmailOTP(email, token, type); - - if (session?.AccessToken != null) - { - return session; - } - - return null; - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } - - - /// - /// Updates a User. - /// - /// - /// - /// - /// - public async Task Update(string accessToken, UserAttributes attributes, StatelessClientOptions options) - { - try - { - var result = await GetApi(options).UpdateUser(accessToken, attributes); - return result; - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } - - /// - /// Sends an invite email link to the specified email. - /// - /// - /// this token needs role 'supabase_admin' or 'service_role' - /// - /// - public async Task InviteUserByEmail(string email, string jwt, StatelessClientOptions options) - { - try - { - var response = await GetApi(options).InviteUserByEmail(email, jwt); - response.ResponseMessage?.EnsureSuccessStatusCode(); - return true; - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } - - /// - /// Sends a reset request to an email address. - /// - /// - /// - /// - /// - public async Task ResetPasswordForEmail(string email, StatelessClientOptions options) - { - try - { - var result = await GetApi(options).ResetPasswordForEmail(email); - result.ResponseMessage?.EnsureSuccessStatusCode(); - return true; - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } - - /// - /// Lists users - /// - /// A valid JWT. Must be a full-access API key (e.g. service_role key). - /// - /// A string for example part of the email - /// Snake case string of the given key, currently only created_at is supported - /// asc or desc, if null desc is used - /// page to show for pagination - /// items per page for pagination - /// - public async Task?> ListUsers(string jwt, StatelessClientOptions options, string? filter = null, string? sortBy = null, SortOrder sortOrder = SortOrder.Descending, int? page = null, int? perPage = null) - { - try - { - return await GetApi(options).ListUsers(jwt, filter, sortBy, sortOrder, page, perPage); - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } - - /// - /// Get User details by Id - /// - /// A valid JWT. Must be a full-access API key (e.g. service_role key). - /// - /// - /// - public async Task GetUserById(string jwt, StatelessClientOptions options, string userId) - { - try - { - return await GetApi(options).GetUserById(jwt, userId); - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } - - /// - /// Get User details by JWT. Can be used to validate a JWT. - /// - /// A valid JWT. Must be a JWT that originates from a user. - /// - /// - public async Task GetUser(string jwt, StatelessClientOptions options) - { - try - { - return await GetApi(options).GetUser(jwt); - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } - - /// - /// Create a user - /// - /// A valid JWT. Must be a full-access API key (e.g. service_role key). - /// - /// - /// - /// - /// - public Task CreateUser(string jwt, StatelessClientOptions options, string email, string password, AdminUserAttributes? attributes = null) - { - if (attributes == null) - { - attributes = new AdminUserAttributes(); - } - attributes.Email = email; - attributes.Password = password; - - return CreateUser(jwt, options, attributes); - } - - /// - /// Create a user - /// - /// A valid JWT. Must be a full-access API key (e.g. service_role key). - /// - /// - /// - public async Task CreateUser(string jwt, StatelessClientOptions options, AdminUserAttributes attributes) - { - try - { - return await GetApi(options).CreateUser(jwt, attributes); - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } - - /// - /// Update user by Id - /// - /// A valid JWT. Must be a full-access API key (e.g. service_role key). - /// - /// - /// - /// - public async Task UpdateUserById(string jwt, StatelessClientOptions options, string userId, AdminUserAttributes userData) - { - try - { - return await GetApi(options).UpdateUserById(jwt, userId, userData); - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } - - /// - /// Deletes a User. - /// - /// - /// this token needs role 'supabase_admin' or 'service_role' - /// - /// - public async Task DeleteUser(string uid, string jwt, StatelessClientOptions options) - { - try - { - var result = await GetApi(options).DeleteUser(uid, jwt); - result.ResponseMessage?.EnsureSuccessStatusCode(); - return true; - } - catch (RequestException ex) - { - throw ExceptionHandler.Parse(ex); - } - } - - /// - /// Parses a out of a 's Query parameters. - /// - /// - /// - /// - public async Task GetSessionFromUrl(Uri uri, StatelessClientOptions options) - { - var query = HttpUtility.ParseQueryString(uri.Query); - - var errorDescription = query.Get("error_description"); - - if (!string.IsNullOrEmpty(errorDescription)) - throw new Exception(errorDescription); - - var accessToken = query.Get("access_token"); - - if (string.IsNullOrEmpty(accessToken)) - throw new Exception("No access_token detected."); - - var expiresIn = query.Get("expires_in"); - - if (string.IsNullOrEmpty(expiresIn)) - throw new Exception("No expires_in detected."); - - var refreshToken = query.Get("refresh_token"); - - if (string.IsNullOrEmpty(refreshToken)) - throw new Exception("No refresh_token detected."); - - var tokenType = query.Get("token_type"); - - if (string.IsNullOrEmpty(tokenType)) - throw new Exception("No token_type detected."); - - var user = await GetApi(options).GetUser(accessToken); - - var session = new Session - { - AccessToken = accessToken, - ExpiresIn = int.Parse(expiresIn), - RefreshToken = refreshToken, - TokenType = tokenType, - User = user - }; - - return session; - } - - /// - /// Refreshes a Token - /// - /// - public async Task RefreshToken(string refreshToken, StatelessClientOptions options) => await GetApi(options).RefreshAccessToken(refreshToken); - - - /// - /// Class representation options available to the . - /// - public class StatelessClientOptions - { - /// - /// Gotrue Endpoint - /// - public string Url { get; set; } = GOTRUE_URL; - - /// - /// Headers to be sent with subsequent requests. - /// - public Dictionary Headers = new Dictionary(DEFAULT_HEADERS); - - /// - /// Very unlikely this flag needs to be changed except in very specific contexts. - /// - /// Enables tests to be E2E tests to be run without requiring users to have - /// confirmed emails - mirrors the Gotrue server's configuration. - /// - public bool AllowUnconfirmedUserSessions { get; set; } - } - } - -} \ No newline at end of file + /// + /// A Stateless Gotrue Client + /// + /// + /// var options = new StatelessClientOptions { Url = "https://mygotrueurl.com" }; + /// var user = await client.SignIn("user@email.com", "fancyPassword", options); + /// + public class StatelessClient : IGotrueStatelessClient + { + public async Task Settings(StatelessClientOptions options) + { + var api = GetApi(options); + return await api.Settings(); + } + + public IGotrueApi GetApi(StatelessClientOptions options) => new Api(options.Url, options.Headers); + + /// + /// Signs up a user by email address + /// + /// + /// + /// + /// Object containing redirectTo and optional user metadata (data) + /// + public Task SignUp(string email, string password, StatelessClientOptions options, SignUpOptions? signUpOptions = null) => SignUp(SignUpType.Email, email, password, options, signUpOptions); + + /// + /// Signs up a user + /// + /// Type of signup + /// Phone or Email + /// + /// + /// Object containing redirectTo and optional user metadata (data) + /// + public async Task SignUp(SignUpType type, string identifier, string password, StatelessClientOptions options, SignUpOptions? signUpOptions = null) + { + var api = GetApi(options); + var session = type switch + { + SignUpType.Email => await api.SignUpWithEmail(identifier, password, signUpOptions), + SignUpType.Phone => await api.SignUpWithPhone(identifier, password, signUpOptions), + _ => null + }; + + if (session?.User?.ConfirmedAt != null || session?.User != null && options.AllowUnconfirmedUserSessions) + { + return session; + } + + return null; + } + + + /// + /// Sends a Magic email login link to the specified email. + /// + /// + /// + /// + /// + public async Task SignIn(string email, StatelessClientOptions options, SignInOptions? signInOptions = null) + { + await GetApi(options).SendMagicLinkEmail(email, signInOptions); + return true; + } + + /// + /// Sends a Magic email login link to the specified email. + /// + /// + /// + /// + /// + public Task SendMagicLink(string email, StatelessClientOptions options, SignInOptions? signInOptions = null) => SignIn(email, options, signInOptions); + + /// + /// Signs in a User. + /// + /// + /// + /// + /// + public Task SignIn(string email, string password, StatelessClientOptions options) => SignIn(SignInType.Email, email, password, options); + + /// + /// Log in an existing user, or login via a third-party provider. + /// + /// Type of Credentials being passed + /// An email, phone, or RefreshToken + /// Password to account (optional if `RefreshToken`) + /// + /// + public async Task SignIn(SignInType type, string identifierOrToken, string? password = null, StatelessClientOptions? options = null) + { + + options ??= new StatelessClientOptions(); + + var api = GetApi(options); + Session? session; + switch (type) + { + case SignInType.Email: + session = await api.SignInWithEmail(identifierOrToken, password!); + break; + case SignInType.Phone: + if (string.IsNullOrEmpty(password)) + { + await api.SendMobileOTP(identifierOrToken); + return null; + } + + session = await api.SignInWithPhone(identifierOrToken, password!); + break; + case SignInType.RefreshToken: + session = await RefreshToken(identifierOrToken, options); + break; + default: throw new ArgumentOutOfRangeException(nameof(type), type, null); + } + + if (session?.User?.ConfirmedAt != null || session?.User != null && options.AllowUnconfirmedUserSessions) + { + return session; + } + + return null; + } + + /// + /// Retrieves a Url to redirect to for signing in with a . + /// + /// This method will need to be combined with when the + /// Application receives the Oauth Callback. + /// + /// + /// var client = Supabase.Gotrue.Client.Initialize(options); + /// var url = client.SignIn(Provider.Github); + /// + /// // Do Redirect User + /// + /// // Example code + /// Application.HasReceivedOauth += async (uri) => { + /// var session = await client.GetSessionFromUri(uri, true); + /// } + /// + /// + /// + /// + /// + public ProviderAuthState SignIn(Provider provider, StatelessClientOptions options, SignInOptions? signInOptions = null) => GetApi(options).GetUriForProvider(provider, signInOptions); + + /// + /// Logout a User + /// This will revoke all refresh tokens for the user. + /// JWT tokens will still be valid for stateless auth until they expire. + /// + /// + /// + /// + public async Task SignOut(string jwt, StatelessClientOptions options) + { + var result = await GetApi(options).SignOut(jwt); + result.ResponseMessage?.EnsureSuccessStatusCode(); + return true; + } + + /// + /// Log in a user given a User supplied OTP received via mobile. + /// + /// The user's phone number. + /// Token sent to the user's phone. + /// + /// + /// + public async Task VerifyOTP(string phone, string token, StatelessClientOptions options, MobileOtpType type = MobileOtpType.SMS) + { + var session = await GetApi(options).VerifyMobileOTP(phone, token, type); + + if (session?.AccessToken != null) + { + return session; + } + + return null; + } + + /// + /// Log in a user give a user supplied OTP received via email. + /// + /// + /// + /// + /// + /// + public async Task VerifyOTP(string email, string token, StatelessClientOptions options, EmailOtpType type = EmailOtpType.MagicLink) + { + var session = await GetApi(options).VerifyEmailOTP(email, token, type); + + if (session?.AccessToken != null) + { + return session; + } + + return null; + } + + + /// + /// Updates a User. + /// + /// + /// + /// + /// + public async Task Update(string accessToken, UserAttributes attributes, StatelessClientOptions options) + { + var result = await GetApi(options).UpdateUser(accessToken, attributes); + return result; + } + + /// + /// Sends an invite email link to the specified email. + /// + /// + /// this token needs role 'supabase_admin' or 'service_role' + /// + /// + public async Task InviteUserByEmail(string email, string jwt, StatelessClientOptions options) + { + var response = await GetApi(options).InviteUserByEmail(email, jwt); + response.ResponseMessage?.EnsureSuccessStatusCode(); + return true; + } + + /// + /// Sends a reset request to an email address. + /// + /// + /// + /// + /// + public async Task ResetPasswordForEmail(string email, StatelessClientOptions options) + { + var result = await GetApi(options).ResetPasswordForEmail(email); + result.ResponseMessage?.EnsureSuccessStatusCode(); + return true; + } + + /// + /// Lists users + /// + /// A valid JWT. Must be a full-access API key (e.g. service_role key). + /// + /// A string for example part of the email + /// Snake case string of the given key, currently only created_at is supported + /// asc or desc, if null desc is used + /// page to show for pagination + /// items per page for pagination + /// + public async Task?> ListUsers(string jwt, StatelessClientOptions options, string? filter = null, string? sortBy = null, SortOrder sortOrder = SortOrder.Descending, + int? page = null, int? perPage = null) + { + return await GetApi(options).ListUsers(jwt, filter, sortBy, sortOrder, page, perPage); + } + + /// + /// Get User details by Id + /// + /// A valid JWT. Must be a full-access API key (e.g. service_role key). + /// + /// + /// + public async Task GetUserById(string jwt, StatelessClientOptions options, string userId) + { + return await GetApi(options).GetUserById(jwt, userId); + } + + /// + /// Get User details by JWT. Can be used to validate a JWT. + /// + /// A valid JWT. Must be a JWT that originates from a user. + /// + /// + public async Task GetUser(string jwt, StatelessClientOptions options) + { + return await GetApi(options).GetUser(jwt); + } + + /// + /// Create a user + /// + /// A valid JWT. Must be a full-access API key (e.g. service_role key). + /// + /// + /// + /// + /// + public Task CreateUser(string jwt, StatelessClientOptions options, string email, string password, AdminUserAttributes? attributes = null) + { + attributes ??= new AdminUserAttributes(); + attributes.Email = email; + attributes.Password = password; + + return CreateUser(jwt, options, attributes); + } + + /// + /// Create a user + /// + /// A valid JWT. Must be a full-access API key (e.g. service_role key). + /// + /// + /// + public async Task CreateUser(string jwt, StatelessClientOptions options, AdminUserAttributes attributes) + { + return await GetApi(options).CreateUser(jwt, attributes); + } + + /// + /// Update user by Id + /// + /// A valid JWT. Must be a full-access API key (e.g. service_role key). + /// + /// + /// + /// + public async Task UpdateUserById(string jwt, StatelessClientOptions options, string userId, AdminUserAttributes userData) + { + return await GetApi(options).UpdateUserById(jwt, userId, userData); + } + + /// + /// Deletes a User. + /// + /// + /// this token needs role 'supabase_admin' or 'service_role' + /// + /// + public async Task DeleteUser(string uid, string jwt, StatelessClientOptions options) + { + var result = await GetApi(options).DeleteUser(uid, jwt); + result.ResponseMessage?.EnsureSuccessStatusCode(); + return true; + } + + /// + /// Parses a out of a 's Query parameters. + /// + /// + /// + /// + public async Task GetSessionFromUrl(Uri uri, StatelessClientOptions options) + { + var query = HttpUtility.ParseQueryString(uri.Query); + + var errorDescription = query.Get("error_description"); + + if (!string.IsNullOrEmpty(errorDescription)) + throw new Exception(errorDescription); + + var accessToken = query.Get("access_token"); + + if (string.IsNullOrEmpty(accessToken)) + throw new Exception("No access_token detected."); + + var expiresIn = query.Get("expires_in"); + + if (string.IsNullOrEmpty(expiresIn)) + throw new Exception("No expires_in detected."); + + var refreshToken = query.Get("refresh_token"); + + if (string.IsNullOrEmpty(refreshToken)) + throw new Exception("No refresh_token detected."); + + var tokenType = query.Get("token_type"); + + if (string.IsNullOrEmpty(tokenType)) + throw new Exception("No token_type detected."); + + var user = await GetApi(options).GetUser(accessToken); + + var session = new Session + { + AccessToken = accessToken, + ExpiresIn = int.Parse(expiresIn), + RefreshToken = refreshToken, + TokenType = tokenType, + User = user + }; + + return session; + } + + /// + /// Refreshes a Token + /// + /// + public async Task RefreshToken(string refreshToken, StatelessClientOptions options) => await GetApi(options).RefreshAccessToken(refreshToken); + + + /// + /// Class representation options available to the . + /// + public class StatelessClientOptions + { + /// + /// Gotrue Endpoint + /// + public string Url { get; set; } = GOTRUE_URL; + + /// + /// Headers to be sent with subsequent requests. + /// + public readonly Dictionary Headers = new Dictionary(); + + /// + /// Very unlikely this flag needs to be changed except in very specific contexts. + /// + /// Enables tests to be E2E tests to be run without requiring users to have + /// confirmed emails - mirrors the Gotrue server's configuration. + /// + public bool AllowUnconfirmedUserSessions { get; set; } + } + } + +} diff --git a/GotrueTests/AnonKeyClientFailureTests.cs b/GotrueTests/AnonKeyClientFailureTests.cs new file mode 100644 index 0000000..1335072 --- /dev/null +++ b/GotrueTests/AnonKeyClientFailureTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Supabase.Gotrue; +using Supabase.Gotrue.Exceptions; +using Supabase.Gotrue.Interfaces; +using static GotrueTests.TestUtils; +using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert; +using static Microsoft.VisualStudio.TestTools.UnitTesting.CollectionAssert; +using static Supabase.Gotrue.Constants.AuthState; +using static Supabase.Gotrue.Exceptions.FailureHint.Reason; + +namespace GotrueTests +{ + [SuppressMessage("ReSharper", "PossibleNullReferenceException")] + [TestClass] + public class AnonKeyClientFailureTests + { + private TestSessionPersistence _persistence; + + private void AuthStateListener(IGotrueClient sender, Constants.AuthState newState) + { + if (_stateChanges.Contains(newState) && newState != SignedOut) + throw new ArgumentException($"State updated twice {newState}"); + + _stateChanges.Add(newState); + } + + private bool AuthStateIsEmpty() + { + return _stateChanges.Count == 0; + } + + [TestInitialize] + public void TestInitializer() + { + _persistence = new TestSessionPersistence(); + _client = new Client(new ClientOptions { AllowUnconfirmedUserSessions = true }); + _client.SetPersistence(_persistence); + _client.AddDebugListener(LogDebug); + _client.AddStateChangedListener(AuthStateListener); + } + + + private Client _client; + + private readonly List _stateChanges = new List(); + + [TestMethod("Client: Sign Up With Bad Password")] + public async Task SignUpUserEmailBadPassword() + { + var email = $"{RandomString(12)}@supabase.io"; + var x = await ThrowsExceptionAsync(async () => + { + await _client.SignUp(email, "x"); + }); + AreEqual(UserBadPassword, x.Reason); + IsNull(_persistence.SavedSession); + Contains(_stateChanges, SignedOut); + AreEqual(1, _stateChanges.Count); + } + + [TestMethod("Client: Sign Up With Bad Email Address")] + public async Task SignUpUserEmailBadEmailAddress() + { + var x = await ThrowsExceptionAsync(async () => + { + await _client.SignUp("not a real email address", PASSWORD); + }); + AreEqual(UserBadEmailAddress, x.Reason); + IsNull(_persistence.SavedSession); + Contains(_stateChanges, SignedOut); + AreEqual(1, _stateChanges.Count); + } + + [TestMethod("Client: Sign up without a phone number")] + public async Task SignUpUserPhone() + { + IsTrue(AuthStateIsEmpty()); + + var phone1 = ""; + var x = await ThrowsExceptionAsync(async () => + { + await _client.SignUp(Constants.SignUpType.Phone, phone1, PASSWORD, new SignUpOptions { Data = new Dictionary { { "firstName", "Testing" } } }); + }); + AreEqual(UserMissingInformation, x.Reason); + IsNull(_persistence.SavedSession); + Contains(_stateChanges, SignedOut); + AreEqual(1, _stateChanges.Count); + } + + [TestMethod("Client: Signs Up the same user twice")] + public async Task SignsUpUserTwiceShouldReturnBadRequest() + { + var email = $"{RandomString(12)}@supabase.io"; + var session = await _client.SignUp(email, PASSWORD); + + IsNotNull(session); + + Contains(_stateChanges, SignedIn); + AreEqual(_client.CurrentSession, _persistence.SavedSession); + _stateChanges.Clear(); + + var x = await ThrowsExceptionAsync(async () => + { + await _client.SignUp(email, PASSWORD); + }); + + AreEqual(UserAlreadyRegistered, x.Reason); + IsNull(_persistence.SavedSession); + Contains(_stateChanges, SignedOut); + AreEqual(1, _stateChanges.Count); + } + + [TestMethod("Client: Bogus refresh token")] + public async Task ClientTriggersTokenRefreshedEvent() + { + var email = $"{RandomString(12)}@supabase.io"; + var user = await _client.SignUp(email, PASSWORD); + IsNotNull(user); + + _client.CurrentSession.RefreshToken = "bogus token"; + + var x = await ThrowsExceptionAsync(async () => + { + await _client.RefreshSession(); + }); + AreEqual(x.Reason, InvalidRefreshToken); + } + + [TestMethod("Client: Send Reset Password Email for unknown email")] + public async Task ClientSendsResetPasswordForEmail() + { + var email = $"{RandomString(12)}@supabase.io"; + var result = await _client.ResetPasswordForEmail(email); + IsTrue(result); + } + + + [TestMethod("Client: Throws Exception on Invalid Username and Password")] + public async Task ClientSignsInUserWrongPassword() + { + var user = $"{RandomString(12)}@supabase.io"; + await _client.SignUp(user, PASSWORD); + await _client.SignOut(); + + await ThrowsExceptionAsync(async () => + { + var result = await _client.SignIn(user, PASSWORD + "$"); + IsNotNull(result); + }); + } + + } + +} diff --git a/GotrueTests/AnonKeyClientTests.cs b/GotrueTests/AnonKeyClientTests.cs new file mode 100644 index 0000000..e52c610 --- /dev/null +++ b/GotrueTests/AnonKeyClientTests.cs @@ -0,0 +1,335 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Supabase.Gotrue; +using Supabase.Gotrue.Interfaces; +using static GotrueTests.TestUtils; +using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert; +using static Microsoft.VisualStudio.TestTools.UnitTesting.CollectionAssert; +using static Supabase.Gotrue.Constants.AuthState; + +namespace GotrueTests +{ + [SuppressMessage("ReSharper", "PossibleNullReferenceException")] + [TestClass] + public class AnonKeyClientTests + { + + private TestSessionPersistence _persistence; + + private void AuthStateListener(IGotrueClient sender, Constants.AuthState newState) + { + if (_stateChanges.Contains(newState)) + Debug.WriteLine($"State updated twice {newState}"); + + _stateChanges.Add(newState); + } + + private bool AuthStateIsEmpty() + { + return _stateChanges.Count == 0; + } + + [TestInitialize] + public void TestInitializer() + { + _persistence = new TestSessionPersistence(); + _client = new Client(new ClientOptions { AllowUnconfirmedUserSessions = true }); + _client.SetPersistence(_persistence); + _client.AddDebugListener(LogDebug); + _client.AddStateChangedListener(AuthStateListener); + } + + private Client _client; + + private readonly List _stateChanges = new List(); + + private void VerifyGoodSession(Session session) + { + Contains(_stateChanges, SignedIn); + AreEqual(_client.CurrentSession, session); + AreEqual(_client.CurrentSession, _persistence.SavedSession); + AreEqual(_client.CurrentUser, session.User); + IsNotNull(session.AccessToken); + IsNotNull(session.RefreshToken); + IsNotNull(session.User); + } + + private void VerifySignedOut() + { + Contains(_stateChanges, SignedOut); + IsNull(_persistence.SavedSession); + IsNull(_client.CurrentSession); + IsNull(_client.CurrentUser); + } + + [TestMethod("Client: Sign Up User")] + public async Task SignUpUserEmail() + { + IsTrue(AuthStateIsEmpty()); + + var email = $"{RandomString(12)}@supabase.io"; + var session = await _client.SignUp(email, PASSWORD); + + VerifyGoodSession(session); + } + + [TestMethod("Client: Load User From Persistence")] + public async Task SaveAndLoadUser() + { + IsTrue(AuthStateIsEmpty()); + + var email = $"{RandomString(12)}@supabase.io"; + var session = await _client.SignUp(email, PASSWORD); + + VerifyGoodSession(session); + + var newPersistence = new TestSessionPersistence(); + newPersistence.SaveSession(session); + var newClient = new Client(new ClientOptions { AllowUnconfirmedUserSessions = true }); + newClient.SetPersistence(newPersistence); + newClient.AddDebugListener(LogDebug); + newClient.AddStateChangedListener(AuthStateListener); + + // Loads the session from storage + newClient.LoadSession(); + + Contains(_stateChanges, SignedIn); + AreEqual(newClient.CurrentSession, newPersistence.SavedSession); + IsNotNull(newClient.CurrentSession.AccessToken); + IsNotNull(newClient.CurrentSession.RefreshToken); + IsNotNull(newClient.CurrentSession.User); + VerifyGoodSession(newClient.CurrentSession); + + // Refresh the session + var refreshedSession = await newClient.RetrieveSessionAsync(); + + VerifyGoodSession(refreshedSession); + } + + [TestMethod("Client: Sign up Phone")] + public async Task SignUpUserPhone() + { + IsTrue(AuthStateIsEmpty()); + + var phone1 = GetRandomPhoneNumber(); + var session = await _client.SignUp(Constants.SignUpType.Phone, phone1, PASSWORD, new SignUpOptions { Data = new Dictionary { { "firstName", "Testing" } } }); + + VerifyGoodSession(session); + + AreEqual("Testing", session.User.UserMetadata["firstName"]); + } + + [TestMethod("Client: Triggers Token Refreshed Event")] + public async Task ClientTriggersTokenRefreshedEvent() + { + var tsc = new TaskCompletionSource(); + + var email = $"{RandomString(12)}@supabase.io"; + + IsTrue(AuthStateIsEmpty()); + + var session = await _client.SignUp(email, PASSWORD); + + VerifyGoodSession(session); + + _client.AddStateChangedListener((_, args) => + { + if (args == TokenRefreshed) + { + tsc.SetResult(_client.CurrentSession.AccessToken); + } + }); + + _stateChanges.Clear(); + + await _client.RefreshSession(); + Contains(_stateChanges, TokenRefreshed); + AreEqual(_client.CurrentSession, _persistence.SavedSession); + + var newToken = await tsc.Task; + IsNotNull(newToken); + AreNotEqual(session.RefreshToken, _client.CurrentSession.RefreshToken); + } + + [TestMethod("Client: Signs In User (Email, Phone, Refresh token)")] + public async Task ClientSignsIn() + { + var email = $"{RandomString(12)}@supabase.io"; + var emailSession = await _client.SignUp(email, PASSWORD); + + VerifyGoodSession(emailSession); + _stateChanges.Clear(); + + await _client.SignOut(); + + VerifySignedOut(); + + _stateChanges.Clear(); + + var session2 = await _client.SignIn(email, PASSWORD); + + VerifyGoodSession(session2); + + _stateChanges.Clear(); + + // Phones + var phone = GetRandomPhoneNumber(); + var phoneSession = await _client.SignUp(Constants.SignUpType.Phone, phone, PASSWORD); + + VerifyGoodSession(phoneSession); + + _stateChanges.Clear(); + + await _client.SignOut(); + + VerifySignedOut(); + + _stateChanges.Clear(); + + emailSession = await _client.SignIn(Constants.SignInType.Phone, phone, PASSWORD); + + VerifyGoodSession(emailSession); + _stateChanges.Clear(); + + // Refresh Token + var refreshToken = emailSession.RefreshToken; + + var newSession = await _client.SignIn(Constants.SignInType.RefreshToken, refreshToken ?? throw new InvalidOperationException()); + AreEqual(_client.CurrentSession, _persistence.SavedSession); + Contains(_stateChanges, TokenRefreshed); + DoesNotContain(_stateChanges, SignedIn); + + IsNotNull(newSession.AccessToken); + IsNotNull(newSession.RefreshToken); + IsNotNull(newSession.User); + } + + [TestMethod("Client: Sends Magic Login Email")] + public async Task ClientSendsMagicLoginEmail() + { + var user = $"{RandomString(12)}@supabase.io"; + var session = await _client.SignUp(user, PASSWORD); + + VerifyGoodSession(session); + _stateChanges.Clear(); + + await _client.SignOut(); + + VerifySignedOut(); + + _stateChanges.Clear(); + + var result = await _client.SignIn(user); + IsTrue(result); + AreEqual(0, _stateChanges.Count); + AreEqual(_client.CurrentSession, _persistence.SavedSession); + } + + [TestMethod("Client: Sends Magic Login Email (Alias)")] + public async Task ClientSendsMagicLoginEmailAlias() + { + var user = $"{RandomString(12)}@supabase.io"; + var user2 = $"{RandomString(12)}@supabase.io"; + var session = await _client.SignUp(user, PASSWORD); + + VerifyGoodSession(session); + + _stateChanges.Clear(); + + await _client.SignOut(); + + VerifySignedOut(); + + var result = await _client.SendMagicLink(user); + var result2 = await _client.SendMagicLink(user2, new SignInOptions { RedirectTo = $"com.{RandomString(12)}.deeplink://login" }); + + IsTrue(result); + IsTrue(result2); + } + + [TestMethod("Client: Returns Auth Url for Provider")] + public async Task ClientReturnsAuthUrlForProvider() + { + var result1 = await _client.SignIn(Constants.Provider.Google); + AreEqual("http://localhost:9999/authorize?provider=google", result1.Uri.ToString()); + + var result2 = await _client.SignIn(Constants.Provider.Google, new SignInOptions { Scopes = "special scopes please" }); + AreEqual("http://localhost:9999/authorize?provider=google&scopes=special+scopes+please", result2.Uri.ToString()); + } + + [TestMethod("Client: Returns Verification Code for Provider")] + public async Task ClientReturnsPKCEVerifier() + { + var result = await _client.SignIn(Constants.Provider.Github, new SignInOptions { FlowType = Constants.OAuthFlowType.PKCE }); + + VerifySignedOut(); + + IsTrue(!string.IsNullOrEmpty(result.PKCEVerifier)); + IsTrue(result.Uri.Query.Contains("flow_type=pkce")); + IsTrue(result.Uri.Query.Contains("code_challenge=")); + IsTrue(result.Uri.Query.Contains("code_challenge_method=s256")); + IsTrue(result.Uri.Query.Contains("provider=github")); + } + + [TestMethod("Client: Update user")] + public async Task ClientUpdateUser() + { + var email = $"{RandomString(12)}@supabase.io"; + var session = await _client.SignUp(email, PASSWORD); + + VerifyGoodSession(session); + + _stateChanges.Clear(); + + var attributes = new UserAttributes { Data = new Dictionary { { "hello", "world" } } }; + var result = await _client.Update(attributes); + IsNotNull(result); + AreEqual(email, _client.CurrentUser.Email); + IsNotNull(_client.CurrentUser.UserMetadata); + Contains(_stateChanges, UserUpdated); + AreEqual(_client.CurrentSession, _persistence.SavedSession); + + await _client.SignOut(); + + } + + [TestMethod("Client: Returns current user")] + public async Task ClientGetUser() + { + var email = $"{RandomString(12)}@supabase.io"; + var newUser = await _client.SignUp(email, PASSWORD); + + AreEqual(email, _client.CurrentUser.Email); + + var userByJWT = await _client.GetUser(newUser.AccessToken ?? throw new InvalidOperationException()); + AreEqual(email, userByJWT.Email); + } + + [TestMethod("Client: Nulls CurrentUser on SignOut")] + public async Task ClientGetUserAfterLogOut() + { + IsTrue(AuthStateIsEmpty()); + var user = $"{RandomString(12)}@supabase.io"; + await _client.SignUp(user, PASSWORD); + Contains(_stateChanges, SignedIn); + + _stateChanges.Clear(); + await _client.SignOut(); + + VerifySignedOut(); + } + + [TestMethod("Client: Send Reset Password Email")] + public async Task ClientSendsResetPasswordForEmail() + { + var email = $"{RandomString(12)}@supabase.io"; + await _client.SignUp(email, PASSWORD); + var result = await _client.ResetPasswordForEmail(email); + IsTrue(result); + } + } +} diff --git a/GotrueTests/ClientTests.cs b/GotrueTests/ClientTests.cs deleted file mode 100644 index ac27698..0000000 --- a/GotrueTests/ClientTests.cs +++ /dev/null @@ -1,427 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IdentityModel.Tokens.Jwt; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.IdentityModel.Tokens; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Supabase.Gotrue; -using static Supabase.Gotrue.Constants; -using Supabase.Gotrue.Exceptions; - -namespace GotrueTests -{ - [TestClass] - public class ClientTests - { - private Client client; - - private string password = "I@M@SuperP@ssWord"; - - private static Random random = new Random(); - - private static string RandomString(int length) - { - const string chars = "abcdefghijklmnopqrstuvwxyz0123456789"; - return new string(Enumerable.Repeat(chars, length).Select(s => s[random.Next(s.Length)]).ToArray()); - } - - private static string GetRandomPhoneNumber() - { - const string chars = "123456789"; - var inner = new string(Enumerable.Repeat(chars, 10).Select(s => s[random.Next(s.Length)]).ToArray()); - - return $"+1{inner}"; - } - - private string GenerateServiceRoleToken() - { - var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("37c304f8-51aa-419a-a1af-06154e63707a")); // using GOTRUE_JWT_SECRET - - var tokenDescriptor = new SecurityTokenDescriptor - { - IssuedAt = DateTime.Now, - Expires = DateTime.UtcNow.AddDays(7), - SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256Signature), - Claims = new Dictionary() { { "role", "service_role" } } - }; - - var tokenHandler = new JwtSecurityTokenHandler(); - var securityToken = tokenHandler.CreateToken(tokenDescriptor); - return tokenHandler.WriteToken(securityToken); - } - - [TestInitialize] - public void TestInitializer() - { - client = new Client(new ClientOptions { AllowUnconfirmedUserSessions = true }); - client.AddDebugListener(LogDebug); - } - private static void LogDebug(string message, Exception e) - { - Debug.WriteLine(message); - if (e != null) - Debug.WriteLine(e); - } - - [TestMethod("Client: Signs Up User")] - public async Task ClientSignsUpUser() - { - Session session; - var email = $"{RandomString(12)}@supabase.io"; - session = await client.SignUp(email, password); - - Assert.IsNotNull(session.AccessToken); - Assert.IsNotNull(session.RefreshToken); - Assert.IsInstanceOfType(session.User, typeof(User)); - - var phone1 = GetRandomPhoneNumber(); - session = await client.SignUp(SignUpType.Phone, phone1, password, new SignUpOptions { Data = new Dictionary { { "firstName", "Testing" } } }); - - Assert.IsNotNull(session.AccessToken); - Assert.AreEqual("Testing", session.User.UserMetadata["firstName"]); - } - - [TestMethod("Client: Signs Up the same user twice should throw BadRequestException")] - public async Task ClientSignsUpUserTwiceShouldReturnBadRequest() - { - var email = $"{RandomString(12)}@supabase.io"; - var result1 = await client.SignUp(email, password); - - Assert.IsNotNull(result1); - - await Assert.ThrowsExceptionAsync(async () => - { - await client.SignUp(email, password); - }); - } - - [TestMethod("Client: Triggers Token Refreshed Event")] - public async Task ClientTriggersTokenRefreshedEvent() - { - var tsc = new TaskCompletionSource(); - - var email = $"{RandomString(12)}@supabase.io"; - var user = await client.SignUp(email, password); - - client.StateChanged += (sender, args) => - { - if (args.State == AuthState.TokenRefreshed) - { - tsc.SetResult(client.CurrentSession.AccessToken); - } - }; - - await client.RefreshSession(); - - var newToken = await tsc.Task; - - Assert.AreNotEqual(user.RefreshToken, client.CurrentSession.RefreshToken); - } - - [TestMethod("Client: Signs In User (Email, Phone, Refresh token)")] - public async Task ClientSignsIn() - { - Session session = null; - string refreshToken = ""; - - // Emails - var email = $"{RandomString(12)}@supabase.io"; - await client.SignUp(email, password); - - await client.SignOut(); - - session = await client.SignIn(email, password); - - Assert.IsNotNull(session.AccessToken); - Assert.IsNotNull(session.RefreshToken); - Assert.IsInstanceOfType(session.User, typeof(User)); - - // Phones - var phone = GetRandomPhoneNumber(); - await client.SignUp(SignUpType.Phone, phone, password); - - await client.SignOut(); - - session = await client.SignIn(SignInType.Phone, phone, password); - - Assert.IsNotNull(session.AccessToken); - Assert.IsNotNull(session.RefreshToken); - Assert.IsInstanceOfType(session.User, typeof(User)); - - // Refresh Token - refreshToken = session.RefreshToken; - - var newSession = await client.SignIn(SignInType.RefreshToken, refreshToken); - - Assert.IsNotNull(newSession.AccessToken); - Assert.IsNotNull(newSession.RefreshToken); - Assert.IsInstanceOfType(newSession.User, typeof(User)); - } - - [TestMethod("Client: Sends Magic Login Email")] - public async Task ClientSendsMagicLoginEmail() - { - var user = $"{RandomString(12)}@supabase.io"; - await client.SignUp(user, password); - - await client.SignOut(); - - var result = await client.SignIn(user); - Assert.IsTrue(result); - } - - [TestMethod("Client: Sends Magic Login Email (Alias)")] - public async Task ClientSendsMagicLoginEmailAlias() - { - var user = $"{RandomString(12)}@supabase.io"; - var user2 = $"{RandomString(12)}@supabase.io"; - await client.SignUp(user, password); - - await client.SignOut(); - - var result = await client.SendMagicLink(user); - var result2 = await client.SendMagicLink(user2, new SignInOptions { RedirectTo = $"com.{RandomString(12)}.deeplink://login" }); - - Assert.IsTrue(result); - Assert.IsTrue(result2); - } - - [TestMethod("Client: Returns Auth Url for Provider")] - public async Task ClientReturnsAuthUrlForProvider() - { - var result1 = await client.SignIn(Provider.Google); - Assert.AreEqual("http://localhost:9999/authorize?provider=google", result1.Uri.ToString()); - - var result2 = await client.SignIn(Provider.Google, new SignInOptions { Scopes = "special scopes please" }); - Assert.AreEqual("http://localhost:9999/authorize?provider=google&scopes=special+scopes+please", result2.Uri.ToString()); - } - - [TestMethod("Client: Returns Verification Code for Provider")] - public async Task ClientReturnsPKCEVerifier() - { - var result = await client.SignIn(Provider.Github, new SignInOptions { FlowType = OAuthFlowType.PKCE }); - - Assert.IsTrue(!string.IsNullOrEmpty(result.PKCEVerifier)); - Assert.IsTrue(result.Uri.Query.Contains("flow_type=pkce")); - Assert.IsTrue(result.Uri.Query.Contains("code_challenge=")); - Assert.IsTrue(result.Uri.Query.Contains("code_challenge_method=s256")); - Assert.IsTrue(result.Uri.Query.Contains("provider=github")); - } - - [TestMethod("Client: Update user")] - public async Task ClientUpdateUser() - { - var email = $"{RandomString(12)}@supabase.io"; - var session = await client.SignUp(email, password); - - var attributes = new UserAttributes { Data = new Dictionary { { "hello", "world" } } }; - var result = await client.Update(attributes); - Assert.AreEqual(email, client.CurrentUser.Email); - Assert.IsNotNull(client.CurrentUser.UserMetadata); - - await client.SignOut(); - var token = GenerateServiceRoleToken(); - var result2 = await client.UpdateUserById(token, session.User.Id, new AdminUserAttributes { UserMetadata = new Dictionary { { "hello", "updated" } } }); - - Assert.AreNotEqual(result.UserMetadata["hello"], result2.UserMetadata["hello"]); - } - - [TestMethod("Client: Returns current user")] - public async Task ClientGetUser() - { - var email = $"{RandomString(12)}@supabase.io"; - var newUser = await client.SignUp(email, password); - - Assert.AreEqual(email, client.CurrentUser.Email); - - var userByJWT = await client.GetUser(newUser.AccessToken); - Assert.AreEqual(email, userByJWT.Email); - } - - [TestMethod("Client: Nulls CurrentUser on SignOut")] - public async Task ClientGetUserAfterLogOut() - { - var user = $"{RandomString(12)}@supabase.io"; - await client.SignUp(user, password); - - await client.SignOut(); - - Assert.IsNull(client.CurrentUser); - } - - [TestMethod("Client: Throws Exception on Invalid Username and Password")] - public async Task ClientSignsInUserWrongPassword() - { - var user = $"{RandomString(12)}@supabase.io"; - await client.SignUp(user, password); - - await client.SignOut(); - - await Assert.ThrowsExceptionAsync(async () => - { - var result = await client.SignIn(user, password + "$"); - }); - } - - [TestMethod("Client: Sends Invite Email")] - public async Task ClientSendsInviteEmail() - { - var user = $"{RandomString(12)}@supabase.io"; - var service_role_key = GenerateServiceRoleToken(); - var result = await client.InviteUserByEmail(user, service_role_key); - Assert.IsTrue(result); - } - - [TestMethod("Client: Lists users")] - public async Task ClientListUsers() - { - var service_role_key = GenerateServiceRoleToken(); - var result = await client.ListUsers(service_role_key); - - Assert.IsTrue(result.Users.Count > 0); - } - - [TestMethod("Client: Lists users pagination")] - public async Task ClientListUsersPagination() - { - var service_role_key = GenerateServiceRoleToken(); - - var page1 = await client.ListUsers(service_role_key, page: 1, perPage: 1); - var page2 = await client.ListUsers(service_role_key, page: 2, perPage: 1); - - Assert.AreEqual(page1.Users.Count, 1); - Assert.AreEqual(page2.Users.Count, 1); - Assert.AreNotEqual(page1.Users[0].Id, page2.Users[0].Id); - } - - [TestMethod("Client: Lists users sort")] - public async Task ClientListUsersSort() - { - var service_role_key = GenerateServiceRoleToken(); - - var result1 = await client.ListUsers(service_role_key, sortBy: "created_at", sortOrder: SortOrder.Ascending); - var result2 = await client.ListUsers(service_role_key, sortBy: "created_at", sortOrder: SortOrder.Descending); - - Assert.AreNotEqual(result1.Users[0].Id, result2.Users[0].Id); - } - - [TestMethod("Client: Lists users filter")] - public async Task ClientListUsersFilter() - { - var service_role_key = GenerateServiceRoleToken(); - - var user = $"{RandomString(12)}@supabase.io"; - var result = await client.SignUp(user, password); - - var result1 = await client.ListUsers(service_role_key, filter: "@nonexistingrandomemailprovider.com"); - var result2 = await client.ListUsers(service_role_key, filter: "@supabase.io"); - - Assert.AreNotEqual(result2.Users.Count, 0); - Assert.AreEqual(result1.Users.Count, 0); - Assert.AreNotEqual(result1.Users.Count, result2.Users.Count); - } - - [TestMethod("Client: Get User by Id")] - public async Task ClientGetUserById() - { - var service_role_key = GenerateServiceRoleToken(); - var result = await client.ListUsers(service_role_key, page: 1, perPage: 1); - - var userResult = result.Users[0]; - var userByIdResult = await client.GetUserById(service_role_key, userResult.Id); - - Assert.AreEqual(userResult.Id, userByIdResult.Id); - Assert.AreEqual(userResult.Email, userByIdResult.Email); - } - - [TestMethod("Client: Create a user")] - public async Task ClientCreateUser() - { - var service_role_key = GenerateServiceRoleToken(); - var result = await client.CreateUser(service_role_key, $"{RandomString(12)}@supabase.io", password); - - Assert.IsNotNull(result); - - - var attributes = new AdminUserAttributes - { - UserMetadata = new Dictionary { { "firstName", "123" } }, - AppMetadata = new Dictionary { { "roles", new List { "editor", "publisher" } } } - }; - - var result2 = await client.CreateUser(service_role_key, $"{RandomString(12)}@supabase.io", password, attributes); - Assert.AreEqual("123", result2.UserMetadata["firstName"]); - - var result3 = await client.CreateUser(service_role_key, new AdminUserAttributes { Email = $"{RandomString(12)}@supabase.io", Password = password }); - Assert.IsNotNull(result3); - } - - - [TestMethod("Client: Update User by Id")] - public async Task ClientUpdateUserById() - { - var service_role_key = GenerateServiceRoleToken(); - var createdUser = await client.CreateUser(service_role_key, $"{RandomString(12)}@supabase.io", password); - - Assert.IsNotNull(createdUser); - - var updatedUser = await client.UpdateUserById(service_role_key, createdUser.Id, new AdminUserAttributes { Email = $"{RandomString(12)}@supabase.io" }); - - Assert.IsNotNull(updatedUser); - - Assert.AreEqual(createdUser.Id, updatedUser.Id); - Assert.AreNotEqual(createdUser.Email, updatedUser.Email); - } - - [TestMethod("Client: Deletes User")] - public async Task ClientDeletesUser() - { - var user = $"{RandomString(12)}@supabase.io"; - await client.SignUp(user, password); - var uid = client.CurrentUser.Id; - - var service_role_key = GenerateServiceRoleToken(); - var result = await client.DeleteUser(uid, service_role_key); - - Assert.IsTrue(result); - } - - [TestMethod("Client: Sends Reset Password Email")] - public async Task ClientSendsResetPasswordForEmail() - { - var email = $"{RandomString(12)}@supabase.io"; - await client.SignUp(email, password); - var result = await client.ResetPasswordForEmail(email); - Assert.IsTrue(result); - } - - [TestMethod("Nonce generation and verification")] - public void NonceGeneration() - { - string nonce = Helpers.GenerateNonce(); - Assert.IsNotNull(nonce); - Assert.AreEqual(128, nonce.Length); - - string pkceVerifier = Helpers.GeneratePKCENonceVerifier(nonce); - Assert.IsNotNull(pkceVerifier); - Assert.AreEqual(43, pkceVerifier.Length); - - string appleVerifier = Helpers.GenerateSHA256NonceFromRawNonce(nonce); - Assert.IsNotNull(appleVerifier); - Assert.AreEqual(64, appleVerifier.Length); - - const string helloNonce = "hello_world_nonce"; - - string helloPkceVerifier = Helpers.GeneratePKCENonceVerifier(helloNonce); - Assert.IsNotNull(helloPkceVerifier); - Assert.AreEqual("9TMmi4JOlYOQEP2Ha39WXj9pySILGnAfQsz-yXws0yE", helloPkceVerifier); - - string helloAppleVerifier = Helpers.GenerateSHA256NonceFromRawNonce(helloNonce); - Assert.IsNotNull(helloAppleVerifier); - Assert.AreEqual("f533268b824e95839010fd876b7f565e3f69c9220b1a701f42ccfec97c2cd321", helloAppleVerifier); - } - } -} diff --git a/GotrueTests/ConfigurationFailureTests.cs b/GotrueTests/ConfigurationFailureTests.cs new file mode 100644 index 0000000..0f36382 --- /dev/null +++ b/GotrueTests/ConfigurationFailureTests.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Supabase.Gotrue; +using Supabase.Gotrue.Exceptions; +using static GotrueTests.TestUtils; +using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert; +using static Supabase.Gotrue.Exceptions.FailureHint.Reason; + +namespace GotrueTests +{ + [TestClass] + public class ConfigurationFailureTests + { + [TestMethod("Bad URL message")] + public async Task BadUrlTest() + { + var client = new Client(new ClientOptions { Url = "https://badprojecturl.supabase.co", AllowUnconfirmedUserSessions = true }); + client.AddDebugListener(LogDebug); + + var email = $"{RandomString(12)}@supabase.io"; + await ThrowsExceptionAsync(async () => + { + await client.SignUp(email, PASSWORD); + }); + } + + [TestMethod("Bad service key message")] + public async Task BadServiceApiKeyTest() + { + var client = new Client(new ClientOptions { AllowUnconfirmedUserSessions = true }); + client.AddDebugListener(LogDebug); + + var x = await ThrowsExceptionAsync(async () => + { + await client.ListUsers("garbage key"); + }); + AreEqual(AdminTokenRequired, x.Reason); + } + } +} diff --git a/GotrueTests/ServiceRoleTests.cs b/GotrueTests/ServiceRoleTests.cs new file mode 100644 index 0000000..6a37783 --- /dev/null +++ b/GotrueTests/ServiceRoleTests.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Supabase.Gotrue; +using static Supabase.Gotrue.Constants; +using static GotrueTests.TestUtils; +using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert; + +namespace GotrueTests +{ + [TestClass] + [SuppressMessage("ReSharper", "PossibleNullReferenceException")] + public class ServiceRoleTests + { + private Client _client; + + private readonly string _serviceKey = GenerateServiceRoleToken(); + + [TestInitialize] + public void TestInitializer() + { + _client = new Client(new ClientOptions { AllowUnconfirmedUserSessions = true }); + _client.AddDebugListener(LogDebug); + } + + [TestMethod("Service Role: Update User")] + public async Task UpdateUser() + { + var email = $"{RandomString(12)}@supabase.io"; + var session = await _client.SignUp(email, PASSWORD); + IsNotNull(session); + + var attributes = new UserAttributes { Data = new Dictionary { { "hello", "world" } } }; + var result = await _client.Update(attributes); + IsNotNull(result); + AreEqual(email, _client.CurrentUser.Email); + IsNotNull(_client.CurrentUser.UserMetadata); + + await _client.SignOut(); + + var result2 = await _client.UpdateUserById(_serviceKey, session.User.Id!, new AdminUserAttributes { UserMetadata = new Dictionary { { "hello", "updated" } } }); + + AreNotEqual(result.UserMetadata["hello"], result2.UserMetadata["hello"]); + } + + [TestMethod("Service Role: Send Invite Email")] + public async Task SendsInviteEmail() + { + var user = $"{RandomString(12)}@supabase.io"; + var result = await _client.InviteUserByEmail(user, _serviceKey); + IsTrue(result); + } + + [TestMethod("Service Role: List users")] + public async Task ListUsers() + { + var result = await _client.ListUsers(_serviceKey); + IsTrue(result.Users.Count > 0); + } + + [TestMethod("Service Role: List users by page")] + public async Task ListUsersPagination() + { + var page1 = await _client.ListUsers(_serviceKey, page: 1, perPage: 1); + var page2 = await _client.ListUsers(_serviceKey, page: 2, perPage: 1); + + AreEqual(page1.Users.Count, 1); + AreEqual(page2.Users.Count, 1); + AreNotEqual(page1.Users[0].Id, page2.Users[0].Id); + } + + [TestMethod("Service Role: Lists users sort")] + public async Task ListUsersSort() + { + var serviceRoleKey = GenerateServiceRoleToken(); + + var result1 = await _client.ListUsers(serviceRoleKey, sortBy: "created_at", sortOrder: SortOrder.Ascending); + var result2 = await _client.ListUsers(serviceRoleKey, sortBy: "created_at", sortOrder: SortOrder.Descending); + + AreNotEqual(result1.Users[0].Id, result2.Users[0].Id); + } + + [TestMethod("Service role: Lists users with filter")] + public async Task ListUsersFilter() + { + var user = $"{RandomString(12)}@supabase.io"; + var result = await _client.SignUp(user, PASSWORD); + IsNotNull(result); + + // ReSharper disable once StringLiteralTypo + var result1 = await _client.ListUsers(_serviceKey, filter: "@nonexistingrandomemailprovider.com"); + var result2 = await _client.ListUsers(_serviceKey, filter: "@supabase.io"); + + AreNotEqual(result2.Users.Count, 0); + AreEqual(result1.Users.Count, 0); + AreNotEqual(result1.Users.Count, result2.Users.Count); + } + + [TestMethod("Service Role: Get User by Id")] + public async Task GetUserById() + { + var result = await _client.ListUsers(_serviceKey, page: 1, perPage: 1); + + var userResult = result.Users[0]; + var userByIdResult = await _client.GetUserById(_serviceKey, userResult.Id ?? throw new InvalidOperationException()); + + AreEqual(userResult.Id, userByIdResult.Id); + AreEqual(userResult.Email, userByIdResult.Email); + } + + [TestMethod("Service Role: Create a user")] + public async Task CreateUser() + { + var result = await _client.CreateUser(_serviceKey, $"{RandomString(12)}@supabase.io", PASSWORD); + + IsNotNull(result); + + var attributes = new AdminUserAttributes + { + UserMetadata = new Dictionary { { "firstName", "123" } }, + AppMetadata = new Dictionary { { "roles", new List { "editor", "publisher" } } } + }; + + var result2 = await _client.CreateUser(_serviceKey, $"{RandomString(12)}@supabase.io", PASSWORD, attributes); + AreEqual("123", result2.UserMetadata["firstName"]); + + var result3 = await _client.CreateUser(_serviceKey, new AdminUserAttributes { Email = $"{RandomString(12)}@supabase.io", Password = PASSWORD }); + IsNotNull(result3); + } + + [TestMethod("Service Role: Update User by Id")] + public async Task UpdateUserById() + { + var createdUser = await _client.CreateUser(_serviceKey, $"{RandomString(12)}@supabase.io", PASSWORD); + + IsNotNull(createdUser); + + var updatedUser = await _client.UpdateUserById(_serviceKey, createdUser.Id ?? throw new InvalidOperationException(), new AdminUserAttributes { Email = $"{RandomString(12)}@supabase.io" }); + + IsNotNull(updatedUser); + + AreEqual(createdUser.Id, updatedUser.Id); + AreNotEqual(createdUser.Email, updatedUser.Email); + } + + [TestMethod("Service Role: Delete User")] + public async Task DeletesUser() + { + var user = $"{RandomString(12)}@supabase.io"; + await _client.SignUp(user, PASSWORD); + var uid = _client.CurrentUser.Id; + + var result = await _client.DeleteUser(uid ?? throw new InvalidOperationException(), _serviceKey); + + IsTrue(result); + } + + [TestMethod("Nonce generation and verification")] + public void NonceGeneration() + { + var nonce = Helpers.GenerateNonce(); + IsNotNull(nonce); + AreEqual(128, nonce.Length); + + var pkceVerifier = Helpers.GeneratePKCENonceVerifier(nonce); + IsNotNull(pkceVerifier); + AreEqual(43, pkceVerifier.Length); + + var appleVerifier = Helpers.GenerateSHA256NonceFromRawNonce(nonce); + IsNotNull(appleVerifier); + AreEqual(64, appleVerifier.Length); + + const string helloNonce = "hello_world_nonce"; + + var helloPkceVerifier = Helpers.GeneratePKCENonceVerifier(helloNonce); + IsNotNull(helloPkceVerifier); + // ReSharper disable once StringLiteralTypo + AreEqual("9TMmi4JOlYOQEP2Ha39WXj9pySILGnAfQsz-yXws0yE", helloPkceVerifier); + + var helloAppleVerifier = Helpers.GenerateSHA256NonceFromRawNonce(helloNonce); + IsNotNull(helloAppleVerifier); + AreEqual("f533268b824e95839010fd876b7f565e3f69c9220b1a701f42ccfec97c2cd321", helloAppleVerifier); + } + } +} diff --git a/GotrueTests/StatelessClientTests.cs b/GotrueTests/StatelessClientTests.cs index 4e27366..2c4df73 100644 --- a/GotrueTests/StatelessClientTests.cs +++ b/GotrueTests/StatelessClientTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Text; @@ -7,29 +8,31 @@ using Microsoft.IdentityModel.Tokens; using Microsoft.VisualStudio.TestTools.UnitTesting; using Supabase.Gotrue; +using Supabase.Gotrue.Exceptions; using static Supabase.Gotrue.StatelessClient; using static Supabase.Gotrue.Constants; -using Supabase.Gotrue.Exceptions; namespace GotrueTests { [TestClass] + [SuppressMessage("ReSharper", "PossibleNullReferenceException")] + [SuppressMessage("ReSharper", "AssignNullToNotNullAttribute")] public class StatelessClientTests { - private string password = "I@M@SuperP@ssWord"; + private const string PASSWORD = "I@M@SuperP@ssWord"; - private static Random random = new Random(); + private static readonly Random Random = new Random(); private static string RandomString(int length) { const string chars = "abcdefghijklmnopqrstuvwxyz0123456789"; - return new string(Enumerable.Repeat(chars, length).Select(s => s[random.Next(s.Length)]).ToArray()); + return new string(Enumerable.Repeat(chars, length).Select(s => s[Random.Next(s.Length)]).ToArray()); } private static string GetRandomPhoneNumber() { const string chars = "123456789"; - var inner = new string(Enumerable.Repeat(chars, 10).Select(s => s[random.Next(s.Length)]).ToArray()); + var inner = new string(Enumerable.Repeat(chars, 10).Select(s => s[Random.Next(s.Length)]).ToArray()); return $"+1{inner}"; } @@ -56,24 +59,35 @@ private string GenerateServiceRoleToken() return tokenHandler.WriteToken(securityToken); } - private StatelessClient client; + private StatelessClient _client; [TestInitialize] public void TestInitializer() { - client = new StatelessClient(); + _client = new StatelessClient(); } - StatelessClientOptions options => new StatelessClientOptions() { AllowUnconfirmedUserSessions = true }; + private static StatelessClientOptions Options { get => new StatelessClientOptions() { AllowUnconfirmedUserSessions = true }; } + [TestMethod("StatelessClient: Settings")] + public async Task Settings() + { + var settings = await _client.Settings(Options); + Assert.IsNotNull(settings); + Assert.IsFalse(settings.Services["zoom"]); + Assert.IsTrue(settings.Services["email"]); + Assert.IsFalse(settings.DisableSignup); + Assert.IsTrue(settings.MailerAutoConfirm); + Assert.IsTrue(settings.PhoneAutoConfirm); + Assert.IsNotNull(settings.SmsProvider); + } [TestMethod("StatelessClient: Signs Up User")] public async Task SignsUpUser() { - Session session = null; var email = $"{RandomString(12)}@supabase.io"; - session = await client.SignUp(email, password, options); + var session = await _client.SignUp(email, PASSWORD, Options); Assert.IsNotNull(session.AccessToken); Assert.IsNotNull(session.RefreshToken); @@ -81,7 +95,7 @@ public async Task SignsUpUser() var phone1 = GetRandomPhoneNumber(); - session = await client.SignUp(SignUpType.Phone, phone1, password, options, new SignUpOptions { Data = new Dictionary { { "firstName", "Testing" } } }); + session = await _client.SignUp(SignUpType.Phone, phone1, PASSWORD, Options, new SignUpOptions { Data = new Dictionary { { "firstName", "Testing" } } }); Assert.IsNotNull(session.AccessToken); Assert.AreEqual("Testing", session.User.UserMetadata["firstName"]); @@ -91,26 +105,23 @@ public async Task SignsUpUser() public async Task SignsUpUserTwiceShouldThrowBadRequest() { var email = $"{RandomString(12)}@supabase.io"; - var result1 = await client.SignUp(email, password, options); + var result1 = await _client.SignUp(email, PASSWORD, Options); + Assert.IsNotNull(result1); - await Assert.ThrowsExceptionAsync(async () => + await Assert.ThrowsExceptionAsync(async () => { - await client.SignUp(email, password, options); + await _client.SignUp(email, PASSWORD, Options); }); } [TestMethod("StatelessClient: Signs In User (Email, Phone, Refresh token)")] public async Task SignsIn() { - Session session = null; - string refreshToken = ""; - // Emails var email = $"{RandomString(12)}@supabase.io"; - await client.SignUp(email, password, options); - + await _client.SignUp(email, PASSWORD, Options); - session = await client.SignIn(email, password, options); + var session = await _client.SignIn(email, PASSWORD, Options); Assert.IsNotNull(session.AccessToken); Assert.IsNotNull(session.RefreshToken); @@ -118,19 +129,19 @@ public async Task SignsIn() // Phones var phone = GetRandomPhoneNumber(); - await client.SignUp(SignUpType.Phone, phone, password, options); + await _client.SignUp(SignUpType.Phone, phone, PASSWORD, Options); - session = await client.SignIn(SignInType.Phone, phone, password, options); + session = await _client.SignIn(SignInType.Phone, phone, PASSWORD, Options); Assert.IsNotNull(session.AccessToken); Assert.IsNotNull(session.RefreshToken); Assert.IsInstanceOfType(session.User, typeof(User)); // Refresh Token - refreshToken = session.RefreshToken; + var refreshToken = session.RefreshToken; - var newSession = await client.SignIn(SignInType.RefreshToken, refreshToken, options: options); + var newSession = await _client.SignIn(SignInType.RefreshToken, refreshToken, options: Options); Assert.IsNotNull(newSession.AccessToken); Assert.IsNotNull(newSession.RefreshToken); @@ -140,19 +151,16 @@ public async Task SignsIn() [TestMethod("StatelessClient: Signs Out User (Email)")] public async Task SignsOut() { - Session session = null; - // Emails var email = $"{RandomString(12)}@supabase.io"; - await client.SignUp(email, password, options); - + await _client.SignUp(email, PASSWORD, Options); - session = await client.SignIn(email, password, options); + var session = await _client.SignIn(email, PASSWORD, Options); Assert.IsNotNull(session.AccessToken); Assert.IsInstanceOfType(session.User, typeof(User)); - var result = await client.SignOut(session.AccessToken, options); + var result = await _client.SignOut(session.AccessToken, Options); Assert.IsTrue(result); } @@ -161,13 +169,13 @@ public async Task SignsOut() public async Task SendsMagicLoginEmail() { var user1 = $"{RandomString(12)}@supabase.io"; - await client.SignUp(user1, password, options); + await _client.SignUp(user1, PASSWORD, Options); - var result1 = await client.SignIn(user1, options); + var result1 = await _client.SignIn(user1, Options); Assert.IsTrue(result1); var user2 = $"{RandomString(12)}@supabase.io"; - var result2 = await client.SignIn(user2, options, new SignInOptions { RedirectTo = $"com.{RandomString(12)}.deeplink://login" }); + var result2 = await _client.SignIn(user2, Options, new SignInOptions { RedirectTo = $"com.{RandomString(12)}.deeplink://login" }); Assert.IsTrue(result2); } @@ -175,23 +183,23 @@ public async Task SendsMagicLoginEmail() public async Task SendsMagicLoginEmailAlias() { var user1 = $"{RandomString(12)}@supabase.io"; - await client.SignUp(user1, password, options); + await _client.SignUp(user1, PASSWORD, Options); - var result1 = await client.SignIn(user1, options); + var result1 = await _client.SignIn(user1, Options); Assert.IsTrue(result1); var user2 = $"{RandomString(12)}@supabase.io"; - var result2 = await client.SignIn(user2, options, new SignInOptions { RedirectTo = $"com.{RandomString(12)}.deeplink://login" }); + var result2 = await _client.SignIn(user2, Options, new SignInOptions { RedirectTo = $"com.{RandomString(12)}.deeplink://login" }); Assert.IsTrue(result2); } [TestMethod("StatelessClient: Returns Auth Url for Provider")] public void ReturnsAuthUrlForProvider() { - var result1 = client.SignIn(Provider.Google, options); + var result1 = _client.SignIn(Provider.Google, Options); Assert.AreEqual("http://localhost:9999/authorize?provider=google", result1.Uri.ToString()); - var result2 = client.SignIn(Provider.Google, options, new SignInOptions { Scopes = "special scopes please" }); + var result2 = _client.SignIn(Provider.Google, Options, new SignInOptions { Scopes = "special scopes please" }); Assert.AreEqual("http://localhost:9999/authorize?provider=google&scopes=special+scopes+please", result2.Uri.ToString()); } @@ -199,7 +207,7 @@ public void ReturnsAuthUrlForProvider() public async Task UpdateUser() { var user = $"{RandomString(12)}@supabase.io"; - var session = await client.SignUp(user, password, options); + var session = await _client.SignUp(user, PASSWORD, Options); var attributes = new UserAttributes { @@ -208,7 +216,7 @@ public async Task UpdateUser() { "hello", "world" } } }; - var updateResult = await client.Update(session.AccessToken, attributes, options); + var updateResult = await _client.Update(session.AccessToken, attributes, Options); Assert.AreEqual(user, updateResult.Email); Assert.IsNotNull(updateResult.UserMetadata); } @@ -217,7 +225,7 @@ public async Task UpdateUser() public async Task GetUser() { var user = $"{RandomString(12)}@supabase.io"; - var session = await client.SignUp(user, password, options); + var session = await _client.SignUp(user, PASSWORD, Options); Assert.AreEqual(user, session.User.Email); } @@ -226,11 +234,11 @@ public async Task GetUser() public async Task SignsInUserWrongPassword() { var user = $"{RandomString(12)}@supabase.io"; - await client.SignUp(user, password, options); + await _client.SignUp(user, PASSWORD, Options); - await Assert.ThrowsExceptionAsync(async () => + await Assert.ThrowsExceptionAsync(async () => { - var result = await client.SignIn(user, password + "$", options); + await _client.SignIn(user, PASSWORD + "$", Options); }); } @@ -238,8 +246,8 @@ await Assert.ThrowsExceptionAsync(async () => public async Task SendsInviteEmail() { var user = $"{RandomString(12)}@supabase.io"; - var service_role_key = GenerateServiceRoleToken(); - var result = await client.InviteUserByEmail(user, service_role_key, options); + var serviceRoleKey = GenerateServiceRoleToken(); + var result = await _client.InviteUserByEmail(user, serviceRoleKey, Options); Assert.IsTrue(result); } @@ -247,11 +255,11 @@ public async Task SendsInviteEmail() public async Task DeletesUser() { var user = $"{RandomString(12)}@supabase.io"; - var session = await client.SignUp(user, password, options); + var session = await _client.SignUp(user, PASSWORD, Options); var uid = session.User.Id; - var service_role_key = GenerateServiceRoleToken(); - var result = await client.DeleteUser(uid, service_role_key, options); + var serviceRoleKey = GenerateServiceRoleToken(); + var result = await _client.DeleteUser(uid, serviceRoleKey, Options); Assert.IsTrue(result); } @@ -260,16 +268,16 @@ public async Task DeletesUser() public async Task ClientSendsResetPasswordForEmail() { var email = $"{RandomString(12)}@supabase.io"; - await client.SignUp(email, password, options); - var result = await client.ResetPasswordForEmail(email, options); + await _client.SignUp(email, PASSWORD, Options); + var result = await _client.ResetPasswordForEmail(email, Options); Assert.IsTrue(result); } [TestMethod("Client: Lists users")] public async Task ClientListUsers() { - var service_role_key = GenerateServiceRoleToken(); - var result = await client.ListUsers(service_role_key, options); + var serviceRoleKey = GenerateServiceRoleToken(); + var result = await _client.ListUsers(serviceRoleKey, Options); Assert.IsTrue(result.Users.Count > 0); } @@ -277,10 +285,10 @@ public async Task ClientListUsers() [TestMethod("Client: Lists users pagination")] public async Task ClientListUsersPagination() { - var service_role_key = GenerateServiceRoleToken(); + var serviceRoleKey = GenerateServiceRoleToken(); - var page1 = await client.ListUsers(service_role_key, options, page: 1, perPage: 1); - var page2 = await client.ListUsers(service_role_key, options, page: 2, perPage: 1); + var page1 = await _client.ListUsers(serviceRoleKey, Options, page: 1, perPage: 1); + var page2 = await _client.ListUsers(serviceRoleKey, Options, page: 2, perPage: 1); Assert.AreEqual(page1.Users.Count, 1); Assert.AreEqual(page2.Users.Count, 1); @@ -290,10 +298,10 @@ public async Task ClientListUsersPagination() [TestMethod("Client: Lists users sort")] public async Task ClientListUsersSort() { - var service_role_key = GenerateServiceRoleToken(); + var serviceRoleKey = GenerateServiceRoleToken(); - var result1 = await client.ListUsers(service_role_key, options, sortBy: "created_at", sortOrder: SortOrder.Descending); - var result2 = await client.ListUsers(service_role_key, options, sortBy: "created_at", sortOrder: SortOrder.Ascending); + var result1 = await _client.ListUsers(serviceRoleKey, Options, sortBy: "created_at", sortOrder: SortOrder.Descending); + var result2 = await _client.ListUsers(serviceRoleKey, Options, sortBy: "created_at", sortOrder: SortOrder.Ascending); Assert.AreNotEqual(result1.Users[0].Id, result2.Users[0].Id); } @@ -301,10 +309,11 @@ public async Task ClientListUsersSort() [TestMethod("Client: Lists users filter")] public async Task ClientListUsersFilter() { - var service_role_key = GenerateServiceRoleToken(); + var serviceRoleKey = GenerateServiceRoleToken(); - var result1 = await client.ListUsers(service_role_key, options, filter: "@nonexistingrandomemailprovider.com"); - var result2 = await client.ListUsers(service_role_key, options, filter: "@supabase.io"); + // https://example.com/ is assigned for use for docs & testing + var result1 = await _client.ListUsers(serviceRoleKey, Options, filter: "@example.com"); + var result2 = await _client.ListUsers(serviceRoleKey, Options, filter: "@supabase.io"); Assert.AreNotEqual(result2.Users.Count, 0); Assert.AreEqual(result1.Users.Count, 0); @@ -314,11 +323,11 @@ public async Task ClientListUsersFilter() [TestMethod("Client: Get User by Id")] public async Task ClientGetUserById() { - var service_role_key = GenerateServiceRoleToken(); - var result = await client.ListUsers(service_role_key, options, page: 1, perPage: 1); + var serviceRoleKey = GenerateServiceRoleToken(); + var result = await _client.ListUsers(serviceRoleKey, Options, page: 1, perPage: 1); var userResult = result.Users[0]; - var userByIdResult = await client.GetUserById(service_role_key, options, userResult.Id); + var userByIdResult = await _client.GetUserById(serviceRoleKey, Options, userResult.Id); Assert.AreEqual(userResult.Id, userByIdResult.Id); Assert.AreEqual(userResult.Email, userByIdResult.Email); @@ -327,8 +336,8 @@ public async Task ClientGetUserById() [TestMethod("Client: Create a user")] public async Task ClientCreateUser() { - var service_role_key = GenerateServiceRoleToken(); - var result = await client.CreateUser(service_role_key, options, $"{RandomString(12)}@supabase.io", password); + var serviceRoleKey = GenerateServiceRoleToken(); + var result = await _client.CreateUser(serviceRoleKey, Options, $"{RandomString(12)}@supabase.io", PASSWORD); Assert.IsNotNull(result); @@ -337,22 +346,22 @@ public async Task ClientCreateUser() UserMetadata = new Dictionary { { "firstName", "123" } }, }; - var result2 = await client.CreateUser(service_role_key, options, $"{RandomString(12)}@supabase.io", password, attributes); + var result2 = await _client.CreateUser(serviceRoleKey, Options, $"{RandomString(12)}@supabase.io", PASSWORD, attributes); Assert.AreEqual("123", result2.UserMetadata["firstName"]); - var result3 = await client.CreateUser(service_role_key, options, new AdminUserAttributes { Email = $"{RandomString(12)}@supabase.io", Password = password }); + var result3 = await _client.CreateUser(serviceRoleKey, Options, new AdminUserAttributes { Email = $"{RandomString(12)}@supabase.io", Password = PASSWORD }); Assert.IsNotNull(result3); } [TestMethod("Client: Update User by Id")] public async Task ClientUpdateUserById() { - var service_role_key = GenerateServiceRoleToken(); - var createdUser = await client.CreateUser(service_role_key, options, $"{RandomString(12)}@supabase.io", password); + var serviceRoleKey = GenerateServiceRoleToken(); + var createdUser = await _client.CreateUser(serviceRoleKey, Options, $"{RandomString(12)}@supabase.io", PASSWORD); Assert.IsNotNull(createdUser); - var updatedUser = await client.UpdateUserById(service_role_key, options, createdUser.Id, new AdminUserAttributes { Email = $"{RandomString(12)}@supabase.io" }); + var updatedUser = await _client.UpdateUserById(serviceRoleKey, Options, createdUser.Id, new AdminUserAttributes { Email = $"{RandomString(12)}@supabase.io" }); Assert.IsNotNull(updatedUser); diff --git a/GotrueTests/TestSessionPersistence.cs b/GotrueTests/TestSessionPersistence.cs new file mode 100644 index 0000000..4324751 --- /dev/null +++ b/GotrueTests/TestSessionPersistence.cs @@ -0,0 +1,26 @@ +using Supabase.Gotrue; +using Supabase.Gotrue.Interfaces; + +namespace GotrueTests +{ + public class TestSessionPersistence : IGotrueSessionPersistence + { + + public Session SavedSession; + + public void DestroySession() + { + SavedSession = null; + } + + public Session LoadSession() + { + return SavedSession; + } + + public void SaveSession(Session session) + { + SavedSession = session; + } + } +} diff --git a/GotrueTests/TestUtils.cs b/GotrueTests/TestUtils.cs new file mode 100644 index 0000000..fc6d48c --- /dev/null +++ b/GotrueTests/TestUtils.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace GotrueTests +{ + public static class TestUtils + { + private static readonly Random Random = new Random(); + + public const string PASSWORD = "I@M@SuperP@ssWord"; + + public static void LogDebug(string message, Exception e) + { + Debug.WriteLine(message); + if (e != null) + Debug.WriteLine(e); + } + + + public static string RandomString(int length) + { + const string chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + return new string(Enumerable.Repeat(chars, length).Select(s => s[Random.Next(s.Length)]).ToArray()); + } + + public static string GetRandomPhoneNumber() + { + const string chars = "123456789"; + var inner = new string(Enumerable.Repeat(chars, 10).Select(s => s[Random.Next(s.Length)]).ToArray()); + return $"+1{inner}"; + } + + public static string GenerateServiceRoleToken() + { + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("37c304f8-51aa-419a-a1af-06154e63707a")); // using GOTRUE_JWT_SECRET + + var tokenDescriptor = new SecurityTokenDescriptor + { + IssuedAt = DateTime.Now, + Expires = DateTime.UtcNow.AddDays(7), + SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256Signature), + Claims = new Dictionary() { { "role", "service_role" } } + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + var securityToken = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(securityToken); + } + + } +} diff --git a/README.md b/README.md index 781d1fb..61b60a3 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,40 @@ --- -## BREAKING CHANGES MOVING FROM v3.0 to 3.1 +## BREAKING CHANGES v3.1 → v3.x -- We've implemented the PKCE auth flow. SignIn using a provider now returns an instance of `ProviderAuthState` rather than a `string`. +- Exceptions have been simplified to a single `GotrueException`. A `Reason` field has been added + to `GotrueException` to clarify what happened. This should also be easier to manage as the Gotrue + server API & messages evolve. +- The session delegates for `Save`/`Load`/`Destroy` have been simplified to no longer require `async`. +- Console logging in a few places (most notable the background refresh thread) has been removed + in favor of a notification method. See `Client.AddDebugListener()` and the test cases for examples. + This will allow you to implement your own logging strategy (write to temp file, console, user visible + err console, etc). +- The client now more reliably emits AuthState changes. +- There is now a single source of truth for headers in the stateful Client - the `Options` headers. + +New feature: + +- Added a `Settings` request to the stateless API only - you can now query the server instance to + determine if it's got the settings you need. This might allow for things like a visual + component in a tool to verify the GoTrue settings are working correctly, or tests that run differently + depending on the server configuration. + +Implementation notes: + +- Test cases have been added to help ensure reliability of auth state change notifications + and persistence. +- Persistence is now managed via the same notifications as auth state change + +## BREAKING CHANGES v3.0 → 3.1 + +- We've implemented the PKCE auth flow. SignIn using a provider now returns an instance of `ProviderAuthState` rather + than a `string`. - The provider sign in signature has moved `scopes` into `SignInOptions` In Short: + ```c# # What was: var url = await client.SignIn(Provider.Github, "scopes and things"); @@ -30,7 +58,9 @@ var state = await client.SignIn(Provider.Github, new SignInOptions { "scopes and ## Getting Started -To use this library on the Supabase Hosted service but separately from the `supabase-csharp`, you'll need to specify your url and public key like so: +To use this library on the Supabase Hosted service but separately from the `supabase-csharp`, you'll need to specify +your url and public key like so: + ```c# var auth = new Supabase.Gotrue.Client(new ClientOptions { @@ -43,6 +73,7 @@ var auth = new Supabase.Gotrue.Client(new ClientOptions ``` Otherwise, using it this library with a local instance: + ```c# var options = new ClientOptions { Url = "https://example.com/api" }; var client = new Client(options); @@ -55,35 +86,47 @@ await new StatelessClient().SignUp("new-user@example.com", options); ## Persisting, Retrieving, and Destroying Sessions. -This Gotrue client is written to be agnostic when it comes to session persistance, retrieval, and destruction. `ClientOptions` exposes +This Gotrue client is written to be agnostic when it comes to session persistence, retrieval, and +destruction. `ClientOptions` exposes properties that allow these to be specified. -In the event these are specified and the `AutoRefreshToken` option is set, as the `Client` Initializes, it will also attempt to +In the event these are specified and the `AutoRefreshToken` option is set, as the `Client` Initializes, it will also +attempt to retrieve, set, and refresh an existing session. For example, using `Xamarin.Essentials` in `Xamarin.Forms`, this might look like: ```c# - -var cacheFileName = ".gotrue.cache"; - +// This is a method you add your application launch/setup async void Initialize() { - var options = new ClientOptions - { - Url = GOTRUE_URL, - SessionPersistor = SessionPersistor, - SessionRetriever = SessionRetriever, - SessionDestroyer = SessionDestroyer - }; - var client = new Client(options); + + // Specify the methods you'd like to use as persistence callbacks + var persistence = new GotrueSessionPersistence(SaveSession, LoadSession, DestroySession); + var client = new Client( + Url = GOTRUE_URL, + new ClientOptions { + AllowUnconfirmedUserSessions = true, + SessionPersistence = persistence }); + + // Specify a debug callback to listen to problems with the background token refresh thread + client.AddDebugListener(LogDebug); + + // Specify a call back to listen to changes in the user state (logged in, out, etc) + client.AddStateChangedListener(AuthStateListener); + + // Load the session from persistence + client.LoadSession(); // Loads the session using SessionRetriever and sets state internally. await client.RetrieveSessionAsync(); } -//... - -internal Task SessionPersistor(Session session) +// Add callback methods for above +// Here's a quick example of using this to save session data to the user's cache folder +// You'll want to add methods for loading the file and deleting when the user logs out +internal bool SaveSession(Session session) { + var cacheFileName = ".gotrue.cache"; + try { var cacheDir = FileSystem.CacheDirectory; @@ -112,11 +155,11 @@ callback, the PKCE flow is preferred: 1) The Callback Url must be set in the Supabase Admin panel 2) The Application should have listener to receive that Callback -3) Generate a sign in request using: `client.SignIn(PROVIDER, options)` and setting the options to use the PKCE `FlowType` +3) Generate a sign in request using: `client.SignIn(PROVIDER, options)` and setting the options to use the + PKCE `FlowType` 4) Store `ProviderAuthState.PKCEVerifier` so that the application callback can use it to verify the returned code 5) In the Callback, use stored `PKCEVerifier` and received `code` to exchange for a session. - ```c# var state = await client.SignIn(Constants.Provider.Github, new SignInOptions { @@ -131,33 +174,48 @@ var session = await client.ExchangeCodeForSession(state.PKCEVerifier, RETRIEVE_C ## Troubleshooting -**I've created a User but while attempting to log in it throws an exception:** +**Q: I've created a User but while attempting to log in it throws an exception:** -Provided the credentials are correct, make sure that the User has also confirmed their email. +A: Provided the credentials are correct, make sure that the User has also confirmed their email. +Adding a handler for email confirmation to a desktop or mobile application can be done, but it +requires setting up URL handlers for each platform, which can be pretty difficult to do if you +aren't really comfortable with configuring these handlers. ( +e.g. [Windows](https://learn.microsoft.com/en-us/windows/win32/search/-search-3x-wds-ph-install-registration), +[Apple](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app), +[Android](https://developer.android.com/training/app-links)) +You may find it easier to create a +simple web application to handle email confirmation - that way a user can just click a link in +their email and get confirmed that way. Your desktop or mobile app should inspect the user object +that comes back and use that to see if the user is confirmed. + +You might find it easiest to do something like create and deploy a +simple [SvelteKit](https://kit.svelte.dev/) or even a very basic +pure [JavaScript](https://github.com/supabase/examples-archive/tree/main/supabase-js-v1/auth/javascript-auth) project +to handle email verification. ## Status - [x] API - - [x] Sign Up with Email - - [x] Sign In with Email - - [x] Send Magic Link Email - - [x] Invite User by Email - - [x] Reset Password for Email - - [x] Signout - - [x] Get Url for Provider - - [x] Get User - - [x] Update User - - [x] Refresh Access Token - - [x] List Users (includes filtering, sorting, pagination) - - [x] Get User by Id - - [x] Create User - - [x] Update User by Id + - [x] Sign Up with Email + - [x] Sign In with Email + - [x] Send Magic Link Email + - [x] Invite User by Email + - [x] Reset Password for Email + - [x] Signout + - [x] Get Url for Provider + - [x] Get User + - [x] Update User + - [x] Refresh Access Token + - [x] List Users (includes filtering, sorting, pagination) + - [x] Get User by Id + - [x] Create User + - [x] Update User by Id - [x] Client - - [x] Get User - - [x] Refresh Session - - [x] Auth State Change Handler - - [x] Provider Sign In (Provides URL) + - [x] Get User + - [x] Refresh Session + - [x] Auth State Change Handler + - [x] Provider Sign In (Provides URL) - [x] Provide Interfaces for Custom Token Persistence Functionality - [x] Documentation - [x] Unit Tests @@ -180,5 +238,6 @@ We are more than happy to have contributions! Please submit a PR. ### Testing To run the tests locally you must have docker and docker-compose installed. Then in the root of the repository run: + - `docker-compose up -d` - `dotnet test` diff --git a/gotrue-csharp.sln.DotSettings b/gotrue-csharp.sln.DotSettings index 0510fb7..48ed401 100644 --- a/gotrue-csharp.sln.DotSettings +++ b/gotrue-csharp.sln.DotSettings @@ -1,48 +1,59 @@  AccessorsWithExpressionBody - Required - False - ExpressionBody - internal private protected public file new static abstract virtual sealed async override extern unsafe volatile readonly required - Remove - BaseClass - NEXT_LINE - NEXT_LINE - 0 - 1 - 0 - 0 - 0 - TOGETHER_SAME_LINE - Tab - NEXT_LINE - NEXT_LINE - True - True - NEXT_LINE - NEVER - True - True - ALWAYS - ALWAYS - ALWAYS - NEVER - False - NEVER - False - False - False - False - NEXT_LINE - WRAP_IF_LONG - CHOP_ALWAYS - 225 - CHOP_ALWAYS - CHOP_ALWAYS - UseVar - UseVar - UseVar - True - True - True - True \ No newline at end of file + Required + False + ExpressionBody + internal private protected public file new static abstract virtual sealed async override extern unsafe volatile readonly required + Remove + BaseClass + NEXT_LINE + NEXT_LINE + 0 + 1 + 0 + 0 + 0 + TOGETHER_SAME_LINE + Tab + NEXT_LINE + NEXT_LINE + True + True + NEXT_LINE + NEVER + True + True + ALWAYS + ALWAYS + ALWAYS + NEVER + False + NEVER + False + False + False + False + NEXT_LINE + WRAP_IF_LONG + CHOP_ALWAYS + 225 + CHOP_ALWAYS + CHOP_ALWAYS + UseVar + UseVar + UseVar + SHA + SMS + True + True + True + True + True + True + True + True + True + True + True + True + True \ No newline at end of file