Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SSO authentication and unit test infra #20

Merged
merged 2 commits into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
.vscode

# Mono auto generated files
mono_crash.*
Expand Down
8 changes: 0 additions & 8 deletions .vscode/settings.json

This file was deleted.

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