Skip to content

Commit

Permalink
feat: enable the authentication scheme name to be specified when conf…
Browse files Browse the repository at this point in the history
…iguring subscription authentication (#111)
  • Loading branch information
rbeauchamp authored Oct 27, 2024
1 parent 0c3da94 commit 4b4c812
Show file tree
Hide file tree
Showing 26 changed files with 698 additions and 286 deletions.
3 changes: 1 addition & 2 deletions .claudesync/config.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,5 @@
"active_organization_id": "c96c6f3e-8134-46d3-b77a-8d1ce8f094c0",
"active_project_id": "8e9e5d1f-a525-49aa-ac38-fcd08a5fefd4",
"active_project_name": "RxDBDotNet",
"default_sync_category": "all_project_files",
"local_path": "."
"default_sync_category": "dotnet"
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ Usage:
});
```

2. The WebSocketJwtAuthInterceptor will automatically use the OIDC configuration for subscription authentication.
2. The SubscriptionJwtAuthInterceptor will automatically use the OIDC configuration for subscription authentication.

## Example Application

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ private static async Task SeedDataAsync(LiveDocsDbContext dbContext)
// Generate a non-expiring JWT token for the system admin user
// We'll use this in the client app to bootstrap the "logged in" state
// since we are not supporting username and password login in this example application
var nonExpiringToken = JwtUtil.GenerateJwtToken(systemAdminReplicatedUser, expires: DateTime.MaxValue);
var tokenParameters = new TokenParameters
{
Expires = DateTime.MaxValue,
};
var nonExpiringToken = JwtUtil.GenerateJwtToken(systemAdminReplicatedUser, tokenParameters);

var systemAdminUser = new User
{
Expand Down
31 changes: 20 additions & 11 deletions example/LiveDocs.GraphQLApi/Security/JwtUtil.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
Expand Down Expand Up @@ -29,7 +30,7 @@ public static class JwtUtil
/// <summary>
/// The issuer of the JWT token, representing the application that generated the token.
/// </summary>
public const string Issuer = "LiveDocsExampleApp";
public const string Issuer = "UnitTestAndExampleApp";

/// <summary>
/// The audience for the JWT token, representing the intended recipients or clients of the token.
Expand All @@ -40,16 +41,18 @@ public static class JwtUtil
/// Generates a JWT token for a given user, capturing the user's id, role, email, and a custom workspace claim.
/// </summary>
/// <param name="user">The <see cref="ReplicatedUser"/> for whom the token is being generated. This parameter cannot be null.</param>
/// <param name="expires">Optional. The expiration time of the token. If not provided, the token will expire in 120 minutes.</param>
/// <param name="tokenParameters">The parameters used for generating the token, including the secret key, issuer, audience, and expiration time.</param>
/// <returns>A JWT token as a <see cref="string"/> that contains the user's claims.</returns>
/// <remarks>
/// This token is valid for 120 minutes and is signed using the HMAC SHA256 algorithm.
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown if the <paramref name="user"/> parameter is null.</exception>
public static string GenerateJwtToken(ReplicatedUser user, DateTime? expires = null)
public static string GenerateJwtToken(ReplicatedUser user, TokenParameters? tokenParameters)
{
ArgumentNullException.ThrowIfNull(user);

tokenParameters ??= new TokenParameters();

var now = DateTimeOffset.UtcNow;
var claims = new List<Claim>
{
Expand All @@ -62,14 +65,15 @@ public static string GenerateJwtToken(ReplicatedUser user, DateTime? expires = n
new(CustomClaimTypes.WorkspaceId, user.WorkspaceId.ToString("D", CultureInfo.InvariantCulture)),
};

var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
Debug.Assert(tokenParameters.SecretKey != null, nameof(tokenParameters.SecretKey) + " != null");
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(tokenParameters.SecretKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);

var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
issuer: tokenParameters.Issuer,
audience: tokenParameters.Audience,
claims: claims,
expires: expires ?? DateTime.UtcNow.AddMinutes(120),
expires: tokenParameters.Expires ?? DateTime.UtcNow.AddMinutes(120),
signingCredentials: credentials);

return new JwtSecurityTokenHandler().WriteToken(token);
Expand All @@ -78,6 +82,7 @@ public static string GenerateJwtToken(ReplicatedUser user, DateTime? expires = n
/// <summary>
/// Retrieves the token validation parameters used for validating JWT tokens.
/// </summary>
/// <param name="tokenParameters">Optional parameters to customize the token validation settings.</param>
/// <returns>A <see cref="TokenValidationParameters"/> object configured with strict validation settings.</returns>
/// <remarks>
/// The validation parameters enforce comprehensive checks on the token, including:
Expand All @@ -93,22 +98,26 @@ public static string GenerateJwtToken(ReplicatedUser user, DateTime? expires = n
/// these settings should be carefully configured according to security requirements.
/// </remarks>
/// <exception cref="SecurityTokenException">Thrown if the validation parameters are misconfigured or if the token fails validation.</exception>
public static TokenValidationParameters GetTokenValidationParameters()
public static TokenValidationParameters GetTokenValidationParameters(TokenParameters? tokenParameters = null)
{
tokenParameters ??= new TokenParameters();

Debug.Assert(tokenParameters.SecretKey != null, "tokenParameters.SecretKey != null");

return new TokenValidationParameters
{
ValidateIssuer = true, // Ensure the token is issued by the correct authority
ValidIssuer = Issuer, // Specify the valid issuer
ValidIssuer = tokenParameters.Issuer, // Specify the valid issuer

ValidateAudience = true, // Ensure the token is intended for the correct audience
ValidAudience = Audience, // Specify the valid audience
ValidAudience = tokenParameters.Audience, // Specify the valid audience

ValidateLifetime = true, // Ensure the token is not expired and is within its valid time range
RequireExpirationTime = true, // Explicitly require that the token contains an expiration time
ClockSkew = TimeSpan.Zero, // Set clock skew to zero to ensure strict expiration checks

ValidateIssuerSigningKey = true, // Ensure the token was signed with the correct signing key
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey)), // Specify the signing key
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(tokenParameters.SecretKey)), // Specify the signing key

RequireSignedTokens = true, // Ensure the token is signed

Expand Down
29 changes: 29 additions & 0 deletions example/LiveDocs.GraphQLApi/Security/TokenParameters.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System;

namespace LiveDocs.GraphQLApi.Security;

/// <summary>
/// Represents the parameters required for generating a JWT token.
/// </summary>
public class TokenParameters
{
/// <summary>
/// The issuer of the token. If not provided, the default JwtUtil.Issuer is used.
/// </summary>
public string? Issuer { get; init; } = JwtUtil.Issuer;

/// <summary>
/// The audience of the token. If not provided, the default JwtUtil.Audience is used.
/// </summary>
public string? Audience { get; init; } = JwtUtil.Audience;

/// <summary>
/// The secret key used to sign the token. If not provided, the default JwtUtil.SecretKey is used.
/// </summary>
public string? SecretKey { get; init; } = JwtUtil.SecretKey;

/// <summary>
/// The expiration time of the token. If not provided, the token will expire in 120 minutes
/// </summary>
public DateTime? Expires { get; init; } = DateTime.UtcNow.AddMinutes(120);
}
4 changes: 2 additions & 2 deletions example/LiveDocs.GraphQLApi/Services/UserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ protected override User Update(ReplicatedUser updatedDocument, User entityToUpda
entityToUpdate.LastName = updatedDocument.LastName;
entityToUpdate.Email = updatedDocument.Email;
entityToUpdate.Role = updatedDocument.Role;
entityToUpdate.JwtAccessToken = JwtUtil.GenerateJwtToken(updatedDocument);
entityToUpdate.JwtAccessToken = JwtUtil.GenerateJwtToken(updatedDocument, new TokenParameters());
entityToUpdate.UpdatedAt = updatedDocument.UpdatedAt;
entityToUpdate.Topics.Clear();
if (updatedDocument.Topics != null)
Expand All @@ -99,7 +99,7 @@ protected override async Task<User> CreateAsync(ReplicatedUser newDocument, Canc
.Where(w => w.ReplicatedDocumentId == newDocument.WorkspaceId)
.SingleAsync(cancellationToken);

var jwtToken = JwtUtil.GenerateJwtToken(newDocument);
var jwtToken = JwtUtil.GenerateJwtToken(newDocument, new TokenParameters());

return new User
{
Expand Down
34 changes: 34 additions & 0 deletions src/RxDBDotNet/Configuration/DocumentOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// src\RxDBDotNet\Configuration\DocumentOptions.cs

using System;
using System.Collections.Generic;
using RxDBDotNet.Documents;

namespace RxDBDotNet.Configuration;

/// <summary>
/// Provides configuration options for replicating documents of type <typeparamref name="TDocument"/>.
/// </summary>
/// <typeparam name="TDocument">
/// The type of document to be replicated, which must implement <see cref="IReplicatedDocument"/>.
/// </typeparam>
public sealed class DocumentOptions<TDocument>
where TDocument : IReplicatedDocument
{
/// <summary>
/// Gets the document-level security options for documents of type <typeparamref name="TDocument"/>.
/// These options control authorization and access control for specific document types.
/// For global security settings like authentication schemes, see <see cref="ReplicationOptions.Security"/>.
/// </summary>
public DocumentSecurityOptions<TDocument> Security { get; set; } = new();

/// <summary>
/// Gets the list of error types that can occur when pushing changes for documents of type <typeparamref name="TDocument"/>.
/// See <see href="https://chillicream.com/docs/hotchocolate/v13/defining-a-schema/mutations/#errors">Hot Chocolate Mutation Errors</see> for more information.
/// </summary>
/// <remarks>
/// These error types are used to handle specific exceptions that may be thrown
/// during the document replication process.
/// </remarks>
public List<Type> Errors { get; } = [];
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// src\RxDBDotNet\Security\SecurityOptions.cs
// src\RxDBDotNet\Configuration\DocumentSecurityOptions.cs

using System;
using System.Collections.Generic;
using RxDBDotNet.Documents;
using RxDBDotNet.Security;

namespace RxDBDotNet.Security;
namespace RxDBDotNet.Configuration;

/// <summary>
/// Provides configuration options for setting up security policies in RxDBDotNet.
Expand All @@ -14,16 +15,16 @@ namespace RxDBDotNet.Security;
/// The type of document that the security options apply to.
/// This type must implement the <see cref="IReplicatedDocument"/> interface.
/// </typeparam>
public sealed class SecurityOptions<TDocument> where TDocument : IReplicatedDocument
public sealed class DocumentSecurityOptions<TDocument> where TDocument : IReplicatedDocument
{
internal List<PolicyRequirement> PolicyRequirements { get; } = [];

/// <summary>
/// Requires a specified policy to be met in order to create the replicated document.
/// </summary>
/// <param name="policy">The policy that must be met for create access.</param>
/// <returns>The current <see cref="SecurityOptions{TDocument}"/> instance for method chaining.</returns>
public SecurityOptions<TDocument> RequirePolicyToCreate(string policy)
/// <returns>The current <see cref="DocumentSecurityOptions{TDocument}"/> instance for method chaining.</returns>
public DocumentSecurityOptions<TDocument> RequirePolicyToCreate(string policy)
{
return RequirePolicy(Operation.Create, policy);
}
Expand All @@ -32,8 +33,8 @@ public SecurityOptions<TDocument> RequirePolicyToCreate(string policy)
/// Requires a specified policy to be met in order to read the replicated document.
/// </summary>
/// <param name="policy">The policy that must be met for read access.</param>
/// <returns>The current <see cref="SecurityOptions{TDocument}"/> instance for method chaining.</returns>
public SecurityOptions<TDocument> RequirePolicyToRead(string policy)
/// <returns>The current <see cref="DocumentSecurityOptions{TDocument}"/> instance for method chaining.</returns>
public DocumentSecurityOptions<TDocument> RequirePolicyToRead(string policy)
{
return RequirePolicy(Operation.Read, policy);
}
Expand All @@ -42,8 +43,8 @@ public SecurityOptions<TDocument> RequirePolicyToRead(string policy)
/// Requires a specified policy to be met in order to update the replicated document.
/// </summary>
/// <param name="policy">The policy that must be met for update access.</param>
/// <returns>The current <see cref="SecurityOptions{TDocument}"/> instance for method chaining.</returns>
public SecurityOptions<TDocument> RequirePolicyToUpdate(string policy)
/// <returns>The current <see cref="DocumentSecurityOptions{TDocument}"/> instance for method chaining.</returns>
public DocumentSecurityOptions<TDocument> RequirePolicyToUpdate(string policy)
{
return RequirePolicy(Operation.Update, policy);
}
Expand All @@ -52,8 +53,8 @@ public SecurityOptions<TDocument> RequirePolicyToUpdate(string policy)
/// Requires a specified policy to be met in order to delete the replicated document.
/// </summary>
/// <param name="policy">The policy that must be met for delete access.</param>
/// <returns>The current <see cref="SecurityOptions{TDocument}"/> instance for method chaining.</returns>
public SecurityOptions<TDocument> RequirePolicyToDelete(string policy)
/// <returns>The current <see cref="DocumentSecurityOptions{TDocument}"/> instance for method chaining.</returns>
public DocumentSecurityOptions<TDocument> RequirePolicyToDelete(string policy)
{
return RequirePolicy(Operation.Delete, policy);
}
Expand All @@ -63,11 +64,11 @@ public SecurityOptions<TDocument> RequirePolicyToDelete(string policy)
/// </summary>
/// <param name="operations">The operations to which the policy applies. This can be a combination of Operation flags.</param>
/// <param name="policy">The policy that must be met for the specified operations.</param>
/// <returns>The current <see cref="SecurityOptions{TDocument}"/> instance for method chaining.</returns>
/// <returns>The current <see cref="DocumentSecurityOptions{TDocument}"/> instance for method chaining.</returns>
/// <exception cref="ArgumentException">
/// Thrown when no operations are specified or when the policy is null or whitespace.
/// </exception>
public SecurityOptions<TDocument> RequirePolicy(Operation operations, string policy)
public DocumentSecurityOptions<TDocument> RequirePolicy(Operation operations, string policy)
{
if (operations == Operation.None)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// src\RxDBDotNet\Security\PolicyRequirement.cs
// src\RxDBDotNet\Configuration\PolicyRequirement.cs

using Microsoft.AspNetCore.Authorization;
using RxDBDotNet.Security;

namespace RxDBDotNet.Security;
namespace RxDBDotNet.Configuration;

/// <summary>
/// Represents a policy requirement for authorizing access to documents in RxDBDotNet.
Expand Down
14 changes: 14 additions & 0 deletions src/RxDBDotNet/Configuration/ReplicationOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// src\RxDBDotNet\Configuration\ReplicationOptions.cs

namespace RxDBDotNet.Configuration;

/// <summary>
/// Provides global configuration options for RxDB replication.
/// </summary>
public class ReplicationOptions
{
/// <summary>
/// Gets security-related configuration options.
/// </summary>
public SecurityOptions Security { get; } = new();
}
33 changes: 33 additions & 0 deletions src/RxDBDotNet/Configuration/SecurityOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// src\RxDBDotNet\Configuration\SecurityOptions.cs

using System.Collections.Generic;
using Microsoft.AspNetCore.Authentication.JwtBearer;
namespace RxDBDotNet.Configuration;

/// <summary>
/// Provides global security-related configuration options for RxDB replication.
/// </summary>
public class SecurityOptions
{
private readonly List<string> _subscriptionAuthenticationSchemes = [JwtBearerDefaults.AuthenticationScheme];

/// <summary>
/// Gets the authentication schemes used for validating Subscription JWT tokens.
/// The default value is a list containing only JwtBearerDefaults.AuthenticationScheme.
/// </summary>
public IReadOnlyList<string> SubscriptionAuthenticationSchemes => _subscriptionAuthenticationSchemes;

/// <summary>
/// Adds an authentication scheme to be used for WebSocket authentication if not already added.
/// </summary>
/// <param name="scheme">The authentication scheme to add.</param>
/// <returns>The current SecurityOptions instance for method chaining.</returns>
public SecurityOptions TryAddSubscriptionAuthenticationScheme(string scheme)
{
if (!_subscriptionAuthenticationSchemes.Contains(scheme))
{
_subscriptionAuthenticationSchemes.Add(scheme);
}
return this;
}
}
8 changes: 8 additions & 0 deletions src/RxDBDotNet/Extensions/DocumentExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ namespace RxDBDotNet.Extensions;

public static class DocumentExtensions
{
/// <summary>
/// Retrieves the GraphQL type name for a specified document type.
/// </summary>
/// <typeparam name="TDocument">The type of the document, which must implement <see cref="IReplicatedDocument"/>.</typeparam>
/// <returns>
/// The GraphQL type name specified by the <see cref="GraphQLNameAttribute"/> if present;
/// otherwise, the name of the document type.
/// </returns>
public static string GetGraphQLTypeName<TDocument>() where TDocument : IReplicatedDocument
{
var attribute = typeof(TDocument).GetCustomAttribute<GraphQLNameAttribute>();
Expand Down
Loading

0 comments on commit 4b4c812

Please sign in to comment.