Skip to content

Commit

Permalink
Merge pull request #3 from speakeasy-sdks/feat/authenticate_request_h…
Browse files Browse the repository at this point in the history
…elpers

feat: implement AuthenticateRequest and VerifyToken JWKS helpers
  • Loading branch information
logangingerich authored Nov 20, 2024
2 parents faf693a + d8a482d commit 6a8bec8
Show file tree
Hide file tree
Showing 16 changed files with 1,189 additions and 2 deletions.
6 changes: 4 additions & 2 deletions .speakeasy/gen.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ generation:
oAuth2ClientCredentialsEnabled: true
oAuth2PasswordEnabled: true
csharp:
version: 0.2.1
additionalDependencies: []
version: 0.2.2
additionalDependencies:
- package: System.IdentityModel.Tokens.Jwt
version: 8.2.0
author: Clerk
clientServerStatusCodesAsErrors: true
defaultErrorName: SDKError
Expand Down
1 change: 1 addition & 0 deletions src/Clerk/BackendAPI/.genignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Helpers/
1 change: 1 addition & 0 deletions src/Clerk/BackendAPI/Clerk.BackendAPI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Please see https://clerk.com/docs for more information.</Description>
<ItemGroup>
<PackageReference Include="newtonsoft.json" Version="13.0.3" />
<PackageReference Include="nodatime" Version="3.1.9" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.0" />
</ItemGroup>

</Project>
93 changes: 93 additions & 0 deletions src/Clerk/BackendAPI/Helpers/AuthenticateRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;

namespace Clerk.BackendAPI.Helpers.Jwks;

/// <summary>
/// AuthenticateRequest - Helper methods to authenticate requests.
/// </summary>
public static class AuthenticateRequest
{
private const string SESSION_COOKIE_NAME = "__session";

/// <summary>
/// Checks if the HTTP request is authenticated.
/// First the session token is retrieved from either the __session cookie
/// or the HTTP Authorization header.
/// Then the session token is verified: networklessly if the options.jwtKey
/// is provided, otherwise by fetching the JWKS from Clerk's Backend API.
/// </summary>
/// <param name="request">The HTTP request</param>
/// <param name="options">The request authentication options</param>
/// <returns>The request state</returns>
/// <remarks>WARNING: AuthenticateRequestAsync is applicable in the context of Backend APIs only.</remarks>
public static async Task<RequestState> AuthenticateRequestAsync(
HttpRequestMessage request,
AuthenticateRequestOptions options)
{
var sessionToken = GetSessionToken(request);
if (sessionToken == null) return RequestState.SignedOut(AuthErrorReason.SESSION_TOKEN_MISSING);

VerifyTokenOptions verifyTokenOptions;

if (options.JwtKey != null)
verifyTokenOptions = new VerifyTokenOptions(
jwtKey: options.JwtKey,
audiences: options.Audiences,
authorizedParties: options.AuthorizedParties,
clockSkewInMs: options.ClockSkewInMs
);
else if (options.SecretKey != null)
verifyTokenOptions = new VerifyTokenOptions(
options.SecretKey,
audiences: options.Audiences,
authorizedParties: options.AuthorizedParties,
clockSkewInMs: options.ClockSkewInMs
);
else
return RequestState.SignedOut(AuthErrorReason.SECRET_KEY_MISSING);

try
{
var claims = await VerifyToken.VerifyTokenAsync(sessionToken, verifyTokenOptions);
return RequestState.SignedIn(sessionToken, claims);
}
catch (TokenVerificationException e)
{
return RequestState.SignedOut(e.Reason);
}
}

/// <summary>
/// Retrieve token from __session cookie or Authorization header.
/// </summary>
/// <param name="request">The HTTP request</param>
/// <returns>The session token, if present</returns>
private static string? GetSessionToken(HttpRequestMessage request)
{
if (request.Headers.TryGetValues("Authorization", out var authorizationHeaders))
{
var bearerToken = authorizationHeaders.FirstOrDefault();
if (!string.IsNullOrEmpty(bearerToken)) return bearerToken.Replace("Bearer ", "");
}

if (request.Headers.TryGetValues("Cookie", out var cookieHeaders))
{
var cookieHeaderValue = cookieHeaders.FirstOrDefault();
if (!string.IsNullOrEmpty(cookieHeaderValue))
{
var cookies = cookieHeaderValue.Split(';')
.Select(cookie => cookie.Trim())
.Select(cookie => new Cookie(cookie.Split('=')[0], cookie.Split('=')[1]));

foreach (var cookie in cookies)
if (cookie.Name == SESSION_COOKIE_NAME)
return cookie.Value;
}
}

return null;
}
}
36 changes: 36 additions & 0 deletions src/Clerk/BackendAPI/Helpers/AuthenticateRequestException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System;

namespace Clerk.BackendAPI.Helpers.Jwks;

public static class AuthErrorReason
{
public static readonly ErrorReason
SESSION_TOKEN_MISSING = new(
"session-token-missing",
"Could not retrieve session token. Please make sure that the __session cookie or the HTTP authorization header contain a Clerk-generated session JWT"
),
SECRET_KEY_MISSING = new(
"secret-key-missing",
"Missing Clerk Secret Key. Go to https://dashboard.clerk.com and get your key for your instance."
);
}

public class AuthenticateRequestException : Exception
{
public readonly ErrorReason Reason;

public AuthenticateRequestException(ErrorReason reason) : base(reason.Message)
{
Reason = reason;
}

public AuthenticateRequestException(ErrorReason reason, Exception cause) : base(reason.Message, cause)
{
Reason = reason;
}

public override string ToString()
{
return Reason.Message;
}
}
42 changes: 42 additions & 0 deletions src/Clerk/BackendAPI/Helpers/AuthenticateRequestOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System.Collections.Generic;

namespace Clerk.BackendAPI.Helpers.Jwks;

public sealed class AuthenticateRequestOptions
{
private static readonly long DEFAULT_CLOCK_SKEW_MS = 5000L;
public readonly IEnumerable<string>? Audiences;
public readonly IEnumerable<string> AuthorizedParties;
public readonly long ClockSkewInMs;
public readonly string? JwtKey;

public readonly string? SecretKey;

/// <summary>
/// Options to configure AuthenticateRequestAsync.
/// </summary>
/// <param name="secretKey">The Clerk secret key from the API Keys page in the Clerk Dashboard. (Optional)</param>
/// <param name="jwtKey">PEM Public String used to verify the session token in a networkless manner. (Optional)</param>
/// <param name="audiences">A list of audiences to verify against.</param>
/// <param name="authorizedParties">An allowlist of origins to verify against.</param>
/// <param name="clockSkewInMs">
/// Allowed time difference (in milliseconds) between the Clerk server (which generates the
/// token) and the clock of the user's application server when validating a token. Defaults to 5000 ms.
/// </param>
public AuthenticateRequestOptions(
string? secretKey = null,
string? jwtKey = null,
IEnumerable<string>? audiences = null,
IEnumerable<string>? authorizedParties = null,
long? clockSkewInMs = null)
{
if (string.IsNullOrEmpty(secretKey) && string.IsNullOrEmpty(jwtKey))
throw new AuthenticateRequestException(AuthErrorReason.SECRET_KEY_MISSING);

SecretKey = secretKey;
JwtKey = jwtKey;
Audiences = audiences;
AuthorizedParties = authorizedParties ?? new List<string>();
ClockSkewInMs = clockSkewInMs ?? DEFAULT_CLOCK_SKEW_MS;
}
}
16 changes: 16 additions & 0 deletions src/Clerk/BackendAPI/Helpers/ErrorReason.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Clerk.BackendAPI.Helpers.Jwks;

/// <summary>
/// Represents the reason for a TokenVerificationException or AuthenticateRequestException.
/// </summary>
public class ErrorReason
{
public readonly string Id;
public readonly string Message;

public ErrorReason(string id, string message)
{
Id = id;
Message = message;
}
}
67 changes: 67 additions & 0 deletions src/Clerk/BackendAPI/Helpers/RequestState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using System.Security.Claims;

namespace Clerk.BackendAPI.Helpers.Jwks;

/// <summary>
/// AuthStatus - The request authentication status.
/// </summary>
public class AuthStatus
{
public static readonly AuthStatus SignedIn = new("signed-in");
public static readonly AuthStatus SignedOut = new("signed-out");

private readonly string value;

private AuthStatus(string value)
{
this.value = value;
}

public string Value()
{
return value;
}
}

/// <summary>
/// RequestState - Authentication State of the request.
/// </summary>
public class RequestState
{
public readonly ClaimsPrincipal? Claims;
public readonly ErrorReason? ErrorReason;
public readonly AuthStatus Status;
public readonly string? Token;


public RequestState(AuthStatus status,
ErrorReason? errorReason,
string? token,
ClaimsPrincipal? claims)
{
Status = status;
ErrorReason = errorReason;
Token = token;
Claims = claims;
}

public static RequestState SignedIn(string token, ClaimsPrincipal claims)
{
return new RequestState(AuthStatus.SignedIn, null, token, claims);
}

public static RequestState SignedOut(ErrorReason errorReason)
{
return new RequestState(AuthStatus.SignedOut, errorReason, null, null);
}

public bool IsSignedIn()
{
return Status == AuthStatus.SignedIn;
}

public bool IsSignedOut()
{
return Status == AuthStatus.SignedOut;
}
}
80 changes: 80 additions & 0 deletions src/Clerk/BackendAPI/Helpers/TokenVerificationException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System;

namespace Clerk.BackendAPI.Helpers.Jwks;

public class TokenVerificationException : Exception
{
public readonly ErrorReason Reason;

public TokenVerificationException(ErrorReason reason) : base(reason.Message)
{
Reason = reason;
}

public TokenVerificationException(ErrorReason reason, Exception cause) : base(reason.Message, cause)
{
Reason = reason;
}

public override string ToString()
{
return Reason.Message;
}
}

public static class TokenVerificationErrorReason
{
public static readonly ErrorReason
JWK_FAILED_TO_LOAD = new(
"jwk-failed-to-load",
"Failed to load JWKS from Clerk Backend API. Contact [email protected]."
),
JWK_REMOTE_INVALID = new(
"jwk-remote-invalid",
"The JWKS endpoint did not contain any signing keys. Contact [email protected]."
),
JWK_LOCAL_INVALID = new(
"jwk-local-invalid",
"The provided PEM Public Key is not in the proper format."
),
JWK_FAILED_TO_RESOLVE = new(
"jwk-failed-to-resolve",
"Failed to resolve JWK. Public Key is not in the proper format."
),
JWK_KID_MISMATCH = new(
"jwk-kid-mismatch",
"Unable to find a signing key in JWKS that matches the kid of the provided session token."
),
TOKEN_EXPIRED = new(
"token-expired",
"Token has expired and is no longer valid."
),
TOKEN_INVALID = new(
"token-invalid",
"Token is invalid and could not be verified."
),
TOKEN_INVALID_AUTHORIZED_PARTIES = new(
"token-invalid-authorized-parties",
"Authorized party claim (azp) does not match any of the authorized parties."
),
TOKEN_INVALID_AUDIENCE = new(
"token-invalid-audience",
"Token audience claim (aud) does not match one of the expected audience values."
),
TOKEN_IAT_IN_THE_FUTURE = new(
"token-iat-in-the-future",
"Token Issued At claim (iat) represents a time in the future."
),
TOKEN_NOT_ACTIVE_YET = new(
"token-not-active-yet",
"Token is not yet valid. Not Before claim (nbf) is in the future."
),
TOKEN_INVALID_SIGNATURE = new(
"token-invalid-signature",
"Token signature is invalid and could not be verified."
),
SECRET_KEY_MISSING = new(
"secret-key-missing",
"Missing Clerk Secret Key. Go to https://dashboard.clerk.com and get your key for your instance."
);
}
Loading

0 comments on commit 6a8bec8

Please sign in to comment.