Skip to content

Commit

Permalink
Add SSO authentication and unit test infra
Browse files Browse the repository at this point in the history
  • Loading branch information
itaihanski committed Apr 17, 2024
1 parent cb92ec3 commit 5d1d31f
Show file tree
Hide file tree
Showing 9 changed files with 259 additions and 5 deletions.
47 changes: 47 additions & 0 deletions Descope.Test/UnitTests/Authentication/SsoTests.cs
Original file line number Diff line number Diff line change
@@ -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<string>(), "", ""), 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);
}
}
}
88 changes: 88 additions & 0 deletions Descope.Test/UnitTests/Utils.cs
Original file line number Diff line number Diff line change
@@ -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<T>(object? o)
{
var s = JsonSerializer.Serialize(o ?? "{}");
var d = JsonSerializer.Deserialize<T>(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<string, Dictionary<string, string?>?, 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<string, Dictionary<string, string?>?, 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<string, object?, Dictionary<string, string?>?, 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<TResponse> Delete<TResponse>(string resource, string pswd, Dictionary<string, string?>? queryParams = null)
{
DeleteCount++;
DeleteAssert?.Invoke(resource, queryParams);
if (DeleteError != null) throw DeleteError;
if (DeleteFailure) throw new Exception();
return Utils.Convert<TResponse>(DeleteResponse);
}

public async Task<TResponse> Get<TResponse>(string resource, string? pswd = null, Dictionary<string, string?>? queryParams = null)
{
GetCount++;
GetAssert?.Invoke(resource, queryParams);
if (GetError != null) throw GetError;
if (GetFailure) throw new Exception();
return Utils.Convert<TResponse>(GetResponse);
}


public async Task<TResponse> Post<TResponse>(string resource, string? pswd = null, object? body = null, Dictionary<string, string?>? queryParams = null)
{
PostCount++;
PostAssert?.Invoke(resource, body, queryParams);
if (PostError != null) throw PostError;
if (PostFailure) throw new Exception();
return Utils.Convert<TResponse>(PostResponse);
}

#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously

}
}
3 changes: 3 additions & 0 deletions Descope/Internal/Authentication/Authentication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -22,6 +24,7 @@ public Authentication(IHttpClient httpClient)
{
_httpClient = httpClient;
_otp = new Otp(httpClient);
_sso = new Sso(httpClient);
}

public async Task<Token> ValidateSession(string sessionJwt)
Expand Down
41 changes: 41 additions & 0 deletions Descope/Internal/Authentication/Sso.cs
Original file line number Diff line number Diff line change
@@ -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<string> Start(string tenant, string? redirectUrl, string? prompt, LoginOptions? loginOptions)
{
Utils.EnforceRequiredArgs(("tenant", tenant));
var body = new { loginOptions };
var queryParams = new Dictionary<string, string?> { { "tenant", tenant }, { "redirectUrl", redirectUrl }, { "prompt", prompt } };
var response = await _httpClient.Post<UrlResponse>(Routes.SsoStart, body: body, queryParams: queryParams);
return response.Url;
}

public async Task<AuthenticationResponse> Exchange(string code)
{
Utils.EnforceRequiredArgs(("code", code));
var body = new { code };
return await _httpClient.Post<AuthenticationResponse>(Routes.SsoExchange, body: body);
}
}

internal class UrlResponse
{
[JsonPropertyName("url")]
public string Url { get; set; }

public UrlResponse(string url)
{
Url = url;
}
}
}
7 changes: 7 additions & 0 deletions Descope/Internal/Http/Routes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions Descope/Internal/Management/Sso.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System.Text.Json.Serialization;

namespace Descope.Internal.Management
namespace Descope.Internal.Management
{
internal class Sso : ISso
{
Expand Down
2 changes: 1 addition & 1 deletion Descope/Internal/Utils/Utils.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace Descope.Internal.Management
namespace Descope.Internal
{
internal class Utils
{
Expand Down
37 changes: 36 additions & 1 deletion Descope/Sdk/Authentication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,51 @@ public interface IOtp
Task<AuthenticationResponse> Verify(DeliveryMethod deliveryMethod, string loginId, string code);
}

/// <summary>
/// Authenticate a user using a SSO.
/// <para>
/// Use the Descope console to configure your SSO details in order for this method to work properly.
/// </para>
/// </summary>
public interface ISsoAuth
{
/// <summary>
/// Initiate a login flow based on tenant configuration (SAML/OIDC).
/// <para>
/// After the redirect chain concludes, finalize the authentication passing the
/// received code the <c>Exchange</c> function.
/// </para>
/// </summary>
/// <param name="tenant">The tenant ID or name, or an email address belonging to a tenant domain</param>
/// <param name="redirectUrl">An optional parameter to generate the SSO link. If not given, the project default will be used.</param>
/// <param name="prompt">Relevant only in case tenant configured with AuthType OIDC</param>
/// <param name="loginOptions">Require additional behaviors when authenticating a user.</param>
/// <returns>The redirect URL that starts the SSO redirect chain</returns>
Task<string> Start(string tenant, string? redirectUrl = null, string? prompt = null, LoginOptions? loginOptions = null);

/// <summary>
/// Finalize SSO authentication by exchanging the received <c>code</c> with an <c>AuthenticationResponse</c>
/// </summary>
/// <param name="code"> The code appended to the returning URL via the <c>code</c> URL parameter.</param>
/// <returns>An <c>AuthenticationResponse</c> value upon successful exchange.</returns>
Task<AuthenticationResponse> Exchange(string code);
}

/// <summary>
/// Provides various APIs for authenticating and authorizing users of a Descope project.
/// </summary>
public interface IAuthentication
{
/// <summary>
/// Provides functions for authenticating users using OTP (one-time password)
/// Authenticate a user using OTP (one-time password).
/// </summary>
public IOtp Otp { get; }

/// <summary>
/// Authenticate a user using a SSO.
/// </summary>
public ISsoAuth Sso { get; }

/// <summary>
/// Validate a session JWT.
/// <para>
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 5d1d31f

Please sign in to comment.