diff --git a/Gotrue/Api.cs b/Gotrue/Api.cs index 0603682a..ec3aaf38 100644 --- a/Gotrue/Api.cs +++ b/Gotrue/Api.cs @@ -114,15 +114,15 @@ public Api(string url, Dictionary? headers = null) /// /// Log in a user using magiclink or a one-time password (OTP). - /// + /// /// If the `{{ .ConfirmationURL }}` variable is specified in the email template, a magiclink will be sent. /// If the `{{ .Token }}` variable is specified in the email template, an OTP will be sent. /// If you're using phone sign-ins, only an OTP will be sent. You won't be able to send a magiclink for phone sign-ins. - /// + /// /// Be aware that you may get back an error message that will not distinguish /// between the cases where the account does not exist or, that the account /// can only be accessed via social login. - /// + /// /// Do note that you will need to configure a Whatsapp sender on Twilio /// if you are using phone sign in with the 'whatsapp' channel. The whatsapp /// channel is not supported on other providers at this time. @@ -160,15 +160,15 @@ public async Task SignInWithOtp(SignInWithPasswordlessE /// /// Log in a user using magiclink or a one-time password (OTP). - /// + /// /// If the `{{ .ConfirmationURL }}` variable is specified in the email template, a magiclink will be sent. /// If the `{{ .Token }}` variable is specified in the email template, an OTP will be sent. /// If you're using phone sign-ins, only an OTP will be sent. You won't be able to send a magiclink for phone sign-ins. - /// + /// /// Be aware that you may get back an error message that will not distinguish /// between the cases where the account does not exist or, that the account /// can only be accessed via social login. - /// + /// /// Do note that you will need to configure a Whatsapp sender on Twilio /// if you are using phone sign in with the 'whatsapp' channel. The whatsapp /// channel is not supported on other providers at this time. @@ -252,6 +252,55 @@ public async Task SignInWithOtp(SignInWithPasswordlessP return Helpers.MakeRequest(HttpMethod.Post, $"{Url}/token?grant_type=id_token", body, Headers); } + private Task SignInWithSsoInternal(Guid? providerId = null, string? domain = null, SignInOptionsWithSsoOptions? options = null) + { + if(providerId != null && domain != null) + throw new GotrueException($"Both providerId and domain were provided to the API, " + + $"you must supply either one or the other but not both providerId={providerId}, domain={domain}"); + if(providerId == null && domain == null) + throw new GotrueException($"Both providerId and domain were null " + + $"you must supply either one or the other but not both providerId={providerId}, domain={domain}"); + + string? codeChallenge = null; + string? codeChallengeMethod = null; + if (options?.FlowType == OAuthFlowType.PKCE) + { + var codeVerifier = Helpers.GenerateNonce(); + codeChallenge = Helpers.GeneratePKCENonceVerifier(codeVerifier); + codeChallengeMethod = "s256"; + } + + var body = new Dictionary + { + { providerId != null ? "provider_id" : "domain", providerId != null ? providerId.ToString() : domain}, + { "redirect_to", options?.RedirectTo }, + + // this is important, it will not auto redirect the request and instead return the Uri needed to handle the login + // without this in the body the request will automatically redirect to the providers sign in page + { "skip_http_redirect", true }, + + { "code_challenge", codeChallenge }, + { "code_challenge_method", codeChallengeMethod } + }; + + if (!string.IsNullOrEmpty(options?.CaptchaToken)) + body.Add("gotrue_meta_security", new Dictionary { { "captcha_token", options?.CaptchaToken } }); + + return Helpers.MakeRequest(HttpMethod.Post, $"{Url}/sso", body, Headers); + } + + /// + public Task SignInWithSso(Guid providerId, SignInOptionsWithSsoOptions? options = null) + { + return SignInWithSsoInternal(providerId: providerId, options: options); + } + + /// + public Task SignInWithSso(string domain, SignInOptionsWithSsoOptions? options = null) + { + return SignInWithSsoInternal(domain: domain, options: options); + } + /// /// Sends a magic login link to an email address. /// @@ -675,4 +724,4 @@ public Task GenerateLink(string jwt, GenerateLinkOptions options) return Helpers.MakeRequest(HttpMethod.Post, $"{Url}/token?grant_type=refresh_token", data, Headers.MergeLeft(headers)); } } -} \ No newline at end of file +} diff --git a/Gotrue/Client.cs b/Gotrue/Client.cs index 74ccb67a..25f48aa5 100644 --- a/Gotrue/Client.cs +++ b/Gotrue/Client.cs @@ -42,14 +42,14 @@ public class Client : IGotrueClient public TokenRefresh? TokenRefresh { get; } /// - /// Initializes the GoTrue stateful client. - /// + /// 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 /// . @@ -62,7 +62,7 @@ public class Client : IGotrueClient /// 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); @@ -295,6 +295,28 @@ public Task SignIn(Provider provider, SignInOptions? options return Task.FromResult(providerUri); } + /// + public Task SignInWithSso(Guid providerId, SignInOptionsWithSsoOptions? options = null) + { + if (!Online) + throw new GotrueException("Only supported when online", Offline); + + DestroySession(); + + return _api.SignInWithSso(providerId, options); + } + + /// + public Task SignInWithSso(string domain, SignInOptionsWithSsoOptions? options = null) + { + if (!Online) + throw new GotrueException("Only supported when online", Offline); + + DestroySession(); + + return _api.SignInWithSso(domain, options); + } + /// public async Task SignInAnonymously(SignInAnonymouslyOptions? options = null) { @@ -711,4 +733,4 @@ public void Shutdown() NotifyAuthStateChange(AuthState.Shutdown); } } -} \ No newline at end of file +} diff --git a/Gotrue/Exceptions/FailureReason.cs b/Gotrue/Exceptions/FailureReason.cs index b16542ad..7d81618f 100644 --- a/Gotrue/Exceptions/FailureReason.cs +++ b/Gotrue/Exceptions/FailureReason.cs @@ -79,7 +79,15 @@ public enum Reason /// /// An invalid authentication flow has been selected. /// - InvalidFlowType + InvalidFlowType, + /// + /// The SSO domain provided was not registered via the CLI + /// + SsoDomainNotFound, + /// + /// The sso provider ID was incorrect or does not exist + /// + SsoProviderNotFound } /// @@ -107,6 +115,8 @@ 400 when gte.Content.Contains("provide") => UserMissingInformation, 401 when gte.Content.Contains("This endpoint requires a Bearer token") => AdminTokenRequired, 403 when gte.Content.Contains("Invalid token") => AdminTokenRequired, 403 when gte.Content.Contains("invalid JWT") => AdminTokenRequired, + 404 when gte.Content.Contains("No SSO provider assigned for this domain") => SsoDomainNotFound, + 404 when gte.Content.Contains("No such SSO provider") => SsoProviderNotFound, 422 when gte.Content.Contains("User already registered") => UserAlreadyRegistered, 422 when gte.Content.Contains("Phone") && gte.Content.Contains("Email") => UserBadMultiple, 422 when gte.Content.Contains("email") && gte.Content.Contains("password") => UserBadMultiple, @@ -118,4 +128,4 @@ 422 when gte.Content.Contains("password") => UserBadPassword, } } -} \ No newline at end of file +} diff --git a/Gotrue/Interfaces/IGotrueApi.cs b/Gotrue/Interfaces/IGotrueApi.cs index 4116fcaf..81cb2ac4 100644 --- a/Gotrue/Interfaces/IGotrueApi.cs +++ b/Gotrue/Interfaces/IGotrueApi.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; using Supabase.Core.Interfaces; using Supabase.Gotrue.Responses; using static Supabase.Gotrue.Constants; @@ -28,6 +29,8 @@ public interface IGotrueApi : IGettableHeaders Task SignInWithOtp(SignInWithPasswordlessEmailOptions options); Task SignInWithOtp(SignInWithPasswordlessPhoneOptions options); Task SignInAnonymously(SignInAnonymouslyOptions? options = null); + Task SignInWithSso(Guid providerId, SignInOptionsWithSsoOptions? options = null); + Task SignInWithSso(string domain, SignInOptionsWithSsoOptions? options = null); Task SignOut(string jwt); Task SignUpWithEmail(string email, string password, SignUpOptions? options = null); Task SignUpWithPhone(string phone, string password, SignUpOptions? options = null); @@ -43,7 +46,7 @@ public interface IGotrueApi : IGettableHeaders /// /// Links an oauth identity to an existing user. - /// + /// /// This method requires the PKCE flow. /// /// User's token @@ -61,4 +64,4 @@ public interface IGotrueApi : IGettableHeaders Task UnlinkIdentity(string token, UserIdentity userIdentity); } -} \ No newline at end of file +} diff --git a/Gotrue/Interfaces/IGotrueClient.cs b/Gotrue/Interfaces/IGotrueClient.cs index b81517eb..bdf3a31a 100644 --- a/Gotrue/Interfaces/IGotrueClient.cs +++ b/Gotrue/Interfaces/IGotrueClient.cs @@ -28,7 +28,7 @@ public interface IGotrueClient : IGettableHeaders { /// /// Indicates if the client should be considered online or offline. - /// + /// /// In a server environment, this client would likely always be online. /// /// On a mobile client, you will want to pair this with a network implementation @@ -45,7 +45,7 @@ public interface IGotrueClient : IGettableHeaders TSession? CurrentSession { get; } /// - /// The currently logged in User. This is a local cache of the current session User. + /// 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. /// > /// @@ -139,7 +139,7 @@ public interface IGotrueClient : IGettableHeaders /// /// Most of the interesting configuration for this flow is done in the /// Supabase/GoTrue admin panel. - /// + /// /// /// /// @@ -148,7 +148,7 @@ public interface IGotrueClient : IGettableHeaders /// /// Sets a new session given a user's access token and their refresh token. - /// + /// /// 1. Will destroy the current session (if existing) /// 2. Raise a event. /// 3. Decode token @@ -190,15 +190,15 @@ public interface IGotrueClient : IGettableHeaders /// /// Log in a user using magiclink or a one-time password (OTP). - /// + /// /// If the `{{ .ConfirmationURL }}` variable is specified in the email template, a magiclink will be sent. /// If the `{{ .Token }}` variable is specified in the email template, an OTP will be sent. /// If you're using phone sign-ins, only an OTP will be sent. You won't be able to send a magiclink for phone sign-ins. - /// + /// /// Be aware that you may get back an error message that will not distinguish /// between the cases where the account does not exist or, that the account /// can only be accessed via social login. - /// + /// /// Do note that you will need to configure a Whatsapp sender on Twilio /// if you are using phone sign in with the 'whatsapp' channel. The whatsapp /// channel is not supported on other providers at this time. @@ -210,15 +210,15 @@ public interface IGotrueClient : IGettableHeaders /// /// Log in a user using magiclink or a one-time password (OTP). - /// + /// /// If the `{{ .ConfirmationURL }}` variable is specified in the email template, a magiclink will be sent. /// If the `{{ .Token }}` variable is specified in the email template, an OTP will be sent. /// If you're using phone sign-ins, only an OTP will be sent. You won't be able to send a magiclink for phone sign-ins. - /// + /// /// Be aware that you may get back an error message that will not distinguish /// between the cases where the account does not exist or, that the account /// can only be accessed via social login. - /// + /// /// Do note that you will need to configure a Whatsapp sender on Twilio /// if you are using phone sign in with the 'whatsapp' channel. The whatsapp /// channel is not supported on other providers at this time. @@ -269,7 +269,30 @@ public interface IGotrueClient : IGettableHeaders /// /// A session where the is_anonymous claim in the access token JWT set to true Task SignInAnonymously(SignInAnonymouslyOptions? options = null); - + + /// + /// Sign in using single sign on (SSO) as supported by supabase + /// To use SSO you need to first set up the providers using the supabase CLI + /// please follow the guide found here: https://supabase.com/docs/guides/auth/enterprise-sso/auth-sso-saml + /// + /// The guid of the provider you wish to use, obtained from running supabase sso list from the CLI + /// The redirect uri and captcha token, if any + /// The Uri returned from supabase auth that a user can use to sign in to their given SSO provider (okta, microsoft entra, gsuite ect...) + Task SignInWithSso(Guid providerId, SignInOptionsWithSsoOptions? options = null); + + /// + /// Sign in using single sign on (SSO) as supported by supabase + /// To use SSO you need to first set up the providers using the supabase CLI + /// please follow the guide found here: https://supabase.com/docs/guides/auth/enterprise-sso/auth-sso-saml + /// + /// + /// Your organizations email domain to use for sign in, this domain needs to already be registered with supabase by running the CLI commands + /// Example: `google.com` + /// + /// The redirect uri and captcha token, if any + /// The Uri returned from supabase auth that a user can use to sign in to their given SSO provider (okta, microsoft entra, gsuite ect...) + Task SignInWithSso(string domain, SignInOptionsWithSsoOptions? options = null); + /// /// Logs in an existing user via a third-party provider. /// @@ -282,7 +305,7 @@ public interface IGotrueClient : IGettableHeaders /// /// /// 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. @@ -379,7 +402,7 @@ public interface IGotrueClient : IGettableHeaders /// Identity to be unlinked /// Task UnlinkIdentity(UserIdentity userIdentity); - + /// /// 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 @@ -432,4 +455,4 @@ public interface IGotrueClient : IGettableHeaders /// public Task RefreshToken(); } -} \ No newline at end of file +} diff --git a/Gotrue/SignInWithSsoOptions.cs b/Gotrue/SignInWithSsoOptions.cs new file mode 100644 index 00000000..78514f3f --- /dev/null +++ b/Gotrue/SignInWithSsoOptions.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Supabase.Gotrue +{ + /// + /// Options used for signing in a user using single sign on (SSO). + /// + public class SignInOptionsWithSsoOptions : SignInOptions + { + /// + /// Verification token received when the user completes the captcha on the site. + /// + [JsonProperty("captchaToken")] + public string? CaptchaToken { get; set; } + } +} diff --git a/Gotrue/SsoResponse.cs b/Gotrue/SsoResponse.cs new file mode 100644 index 00000000..92d86d98 --- /dev/null +++ b/Gotrue/SsoResponse.cs @@ -0,0 +1,16 @@ +using System; + +namespace Supabase.Gotrue +{ + /// + /// Single sign on (SSO) response data deserialized from the API {supabaseAuthUrl}/sso + /// + public class SsoResponse : ProviderAuthState + { + /// + /// Deserialized response from {supabaseAuthUrl}/sso + /// + /// Uri from the response, this will open the SSO providers login page and allow a user to login to their provider + public SsoResponse(Uri uri) : base(uri) { } + } +} diff --git a/README.md b/README.md index 3e394d9a..c9abdfa9 100644 --- a/README.md +++ b/README.md @@ -266,11 +266,13 @@ to handle email verification. - [x] Get User by Id - [x] Create User - [x] Update User by Id + - [x] Sign In with Single Sign On (SSO) - [x] Client - [x] Get User - [x] Refresh Session - [x] Auth State Change Handler - [x] Provider Sign In (Provides URL) + - [x] Sign In with Single Sign On (SSO) - [x] Provide Interfaces for Custom Token Persistence Functionality - [x] Documentation - [x] Unit Tests