diff --git a/.gitignore b/.gitignore index 0eedf5d..df8f9d1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs +.vscode # Mono auto generated files mono_crash.* diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index bbcc6f6..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "xml.format.maxLineWidth": 0, - "workbench.colorCustomizations": { - "activityBar.background": "#3A2B16", - "titleBar.activeBackground": "#513C1E", - "titleBar.activeForeground": "#FCFAF7" - } -} \ No newline at end of file diff --git a/Descope.Test/UnitTests/Authentication/SsoTests.cs b/Descope.Test/UnitTests/Authentication/SsoTests.cs new file mode 100644 index 0000000..a171412 --- /dev/null +++ b/Descope.Test/UnitTests/Authentication/SsoTests.cs @@ -0,0 +1,47 @@ +using Descope.Internal; +using Descope.Internal.Auth; +using Xunit; + +namespace Descope.Test.Unit +{ + public class SsoTests + { + + [Fact] + public async Task SSO_Start() + { + var client = new MockHttpClient(); + ISsoAuth sso = new Sso(client); + client.PostResponse = new { url = "url" }; + client.PostAssert = (url, body, queryParams) => + { + Assert.Equal(Routes.SsoStart, url); + Assert.Equal("tenant", queryParams!["tenant"]); + Assert.Equal("redirectUrl", queryParams!["redirectUrl"]); + Assert.Equal("prompt", queryParams!["prompt"]); + Assert.Contains("\"stepup\":true", Utils.Serialize(body!)); + return null; + }; + var response = await sso.Start("tenant", redirectUrl: "redirectUrl", prompt: "prompt", loginOptions: new LoginOptions { StepUp = true }); + Assert.Equal("url", response); + Assert.Equal(1, client.PostCount); + } + + [Fact] + public async Task SSO_Exchange() + { + var client = new MockHttpClient(); + ISsoAuth sso = new Sso(client); + client.PostResponse = new AuthenticationResponse("", "", "", "", 0, 0, new UserResponse(new List(), "", ""), false); + client.PostAssert = (url, body, queryParams) => + { + Assert.Equal(Routes.SsoExchange, url); + Assert.Null(queryParams); + Assert.Contains("\"code\":\"code\"", Utils.Serialize(body!)); + return null; + }; + var response = await sso.Exchange("code"); + Assert.Equal(1, client.PostCount); + } + } +} diff --git a/Descope.Test/UnitTests/Utils.cs b/Descope.Test/UnitTests/Utils.cs new file mode 100644 index 0000000..b864dcd --- /dev/null +++ b/Descope.Test/UnitTests/Utils.cs @@ -0,0 +1,88 @@ +using Descope.Internal; +using JsonSerializer = System.Text.Json.JsonSerializer; + +namespace Descope.Test.Unit +{ + internal class Utils + { + public static string Serialize(object o) + { + return JsonSerializer.Serialize(o); + } + + public static T Convert(object? o) + { + var s = JsonSerializer.Serialize(o ?? "{}"); + var d = JsonSerializer.Deserialize(s); + return d ?? throw new Exception("Conversion error"); + } + } + + internal class MockHttpClient : IHttpClient + { + + // Delete + public bool DeleteFailure { get; set; } + public Exception? DeleteError { get; set; } + public int DeleteCount { get; set; } + public Func?, object?>? DeleteAssert { get; set; } + public object? DeleteResponse { get; set; } + + // Get + public bool GetFailure { get; set; } + public Exception? GetError { get; set; } + public int GetCount { get; set; } + public Func?, object?>? GetAssert { get; set; } + public object? GetResponse { get; set; } + + // Post + public bool PostFailure { get; set; } + public Exception? PostError { get; set; } + public int PostCount { get; set; } + public Func?, object?>? PostAssert { get; set; } + public object? PostResponse { get; set; } + + // IHttpClient Properties + public DescopeConfig DescopeConfig { get; set; } + + public MockHttpClient() + { + DescopeConfig = new DescopeConfig(projectId: "test"); + } + + // IHttpClient Implementation + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + + public async Task Delete(string resource, string pswd, Dictionary? queryParams = null) + { + DeleteCount++; + DeleteAssert?.Invoke(resource, queryParams); + if (DeleteError != null) throw DeleteError; + if (DeleteFailure) throw new Exception(); + return Utils.Convert(DeleteResponse); + } + + public async Task Get(string resource, string? pswd = null, Dictionary? queryParams = null) + { + GetCount++; + GetAssert?.Invoke(resource, queryParams); + if (GetError != null) throw GetError; + if (GetFailure) throw new Exception(); + return Utils.Convert(GetResponse); + } + + + public async Task Post(string resource, string? pswd = null, object? body = null, Dictionary? queryParams = null) + { + PostCount++; + PostAssert?.Invoke(resource, body, queryParams); + if (PostError != null) throw PostError; + if (PostFailure) throw new Exception(); + return Utils.Convert(PostResponse); + } + +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + + } +} diff --git a/Descope/Internal/Authentication/Authentication.cs b/Descope/Internal/Authentication/Authentication.cs index 47e3a67..32f243f 100644 --- a/Descope/Internal/Authentication/Authentication.cs +++ b/Descope/Internal/Authentication/Authentication.cs @@ -8,8 +8,10 @@ namespace Descope.Internal.Auth public class Authentication : IAuthentication { public IOtp Otp { get => _otp; } + public ISsoAuth Sso { get => _sso; } private readonly Otp _otp; + private readonly Sso _sso; private readonly IHttpClient _httpClient; private readonly JsonWebTokenHandler _jsonWebTokenHandler = new(); @@ -22,6 +24,7 @@ public Authentication(IHttpClient httpClient) { _httpClient = httpClient; _otp = new Otp(httpClient); + _sso = new Sso(httpClient); } public async Task ValidateSession(string sessionJwt) diff --git a/Descope/Internal/Authentication/Sso.cs b/Descope/Internal/Authentication/Sso.cs new file mode 100644 index 0000000..005d90d --- /dev/null +++ b/Descope/Internal/Authentication/Sso.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; + +namespace Descope.Internal.Auth +{ + public class Sso : ISsoAuth + { + private readonly IHttpClient _httpClient; + + public Sso(IHttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task Start(string tenant, string? redirectUrl, string? prompt, LoginOptions? loginOptions) + { + Utils.EnforceRequiredArgs(("tenant", tenant)); + var body = new { loginOptions }; + var queryParams = new Dictionary { { "tenant", tenant }, { "redirectUrl", redirectUrl }, { "prompt", prompt } }; + var response = await _httpClient.Post(Routes.SsoStart, body: body, queryParams: queryParams); + return response.Url; + } + + public async Task Exchange(string code) + { + Utils.EnforceRequiredArgs(("code", code)); + var body = new { code }; + return await _httpClient.Post(Routes.SsoExchange, body: body); + } + } + + internal class UrlResponse + { + [JsonPropertyName("url")] + public string Url { get; set; } + + public UrlResponse(string url) + { + Url = url; + } + } +} diff --git a/Descope/Internal/Http/Routes.cs b/Descope/Internal/Http/Routes.cs index 0437e50..cfdcc14 100644 --- a/Descope/Internal/Http/Routes.cs +++ b/Descope/Internal/Http/Routes.cs @@ -27,6 +27,13 @@ public static class Routes #endregion OTP + #region SSO + + public const string SsoStart = "/v1/auth/sso/authorize"; + public const string SsoExchange = "/v1/auth/sso/exchange"; + + #endregion SSO + #endregion Auth #region Management diff --git a/Descope/Internal/Management/Sso.cs b/Descope/Internal/Management/Sso.cs index d94737e..6875c91 100644 --- a/Descope/Internal/Management/Sso.cs +++ b/Descope/Internal/Management/Sso.cs @@ -1,6 +1,4 @@ -using System.Text.Json.Serialization; - -namespace Descope.Internal.Management +namespace Descope.Internal.Management { internal class Sso : ISso { diff --git a/Descope/Internal/Utils/Utils.cs b/Descope/Internal/Utils/Utils.cs index 9e08861..f89717c 100644 --- a/Descope/Internal/Utils/Utils.cs +++ b/Descope/Internal/Utils/Utils.cs @@ -1,4 +1,4 @@ -namespace Descope.Internal.Management +namespace Descope.Internal { internal class Utils { diff --git a/Descope/Sdk/Authentication.cs b/Descope/Sdk/Authentication.cs index 677bf81..e9e1d14 100644 --- a/Descope/Sdk/Authentication.cs +++ b/Descope/Sdk/Authentication.cs @@ -18,16 +18,51 @@ public interface IOtp Task Verify(DeliveryMethod deliveryMethod, string loginId, string code); } + /// + /// Authenticate a user using a SSO. + /// + /// Use the Descope console to configure your SSO details in order for this method to work properly. + /// + /// + public interface ISsoAuth + { + /// + /// Initiate a login flow based on tenant configuration (SAML/OIDC). + /// + /// After the redirect chain concludes, finalize the authentication passing the + /// received code the Exchange function. + /// + /// + /// The tenant ID or name, or an email address belonging to a tenant domain + /// An optional parameter to generate the SSO link. If not given, the project default will be used. + /// Relevant only in case tenant configured with AuthType OIDC + /// Require additional behaviors when authenticating a user. + /// The redirect URL that starts the SSO redirect chain + Task Start(string tenant, string? redirectUrl = null, string? prompt = null, LoginOptions? loginOptions = null); + + /// + /// Finalize SSO authentication by exchanging the received code with an AuthenticationResponse + /// + /// The code appended to the returning URL via the code URL parameter. + /// An AuthenticationResponse value upon successful exchange. + Task Exchange(string code); + } + /// /// Provides various APIs for authenticating and authorizing users of a Descope project. /// public interface IAuthentication { /// - /// Provides functions for authenticating users using OTP (one-time password) + /// Authenticate a user using OTP (one-time password). /// public IOtp Otp { get; } + /// + /// Authenticate a user using a SSO. + /// + public ISsoAuth Sso { get; } + /// /// Validate a session JWT. /// diff --git a/README.md b/README.md index 193d734..8a4b424 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ var descopeClient = new DescopeClient(config); These sections show how to use the SDK to perform various authentication/authorization functions: 1. [OTP Authentication](#otp-authentication) +2. [SSO Authentication](#sso-saml--oidc) ## Management Functions @@ -81,6 +82,40 @@ catch The session and refresh JWTs should be returned to the caller, and passed with every request in the session. Read more on [session validation](#session-validation) +### SSO (SAML / OIDC) + +Users can authenticate to a specific tenant using SAML or OIDC. Configure your SSO (SAML / OIDC) settings on the [Descope console](https://app.descope.com/settings/authentication/sso). To start a flow call: + +```cs +// Choose which tenant to log into +// If configured globally, the redirect URL is optional. If provided however, it will be used +// instead of any global configuration. +// Redirect the user to the returned URL to start the SSO SAML/OIDC redirect chain +try +{ + var redirectUrl = await descopeClient.Auth.Sso.Start(tenant: "my-tenant-ID", redirectUrl: "https://my-app.com/handle-saml") +} +catch +{ + // handle error +} +``` + +The user will authenticate with the authentication provider configured for that tenant, and will be redirected back to the redirect URL, with an appended `code` HTTP URL parameter. Exchange it to validate the user: + +```cs +try +{ + var authInfo = await descopeClient.Auth.Sso.Exchange(code); +} +catch +{ + // handle error +} +``` + +The session and refresh JWTs should be returned to the caller, and passed with every request in the session. Read more on [session validation](#session-validation) + ### Session Validation Every secure request performed between your client and server needs to be validated. The client sends