Skip to content
Draft
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
89 changes: 84 additions & 5 deletions ReadMe.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,90 @@
# AvantiPoint Packages Templates

The following is a dotnet template for a basic NuGet Package feed using AvantiPoint Packages. This feed uses Azure Active Directory to authenticate users in the web. Authenticated users can then create and manage their own Auth Tokens for use with the package feed. By default only the first user has Package Publishing privileges. You can change this or implement more complex user management scenarios.
The following is a dotnet template for a basic NuGet Package feed using AvantiPoint Packages. This feed uses JWT-based authentication with OAuth providers (Microsoft or Google) to authenticate users in the web interface. Authenticated users can then create and manage their own Auth Tokens for use with the package feed. By default only the first user has Package Publishing privileges. You can change this or implement more complex user management scenarios.

In addition to this the NuGet Package Authentication, and Callback Handlers are pre-wired up, and come with an Email Service and basic html templates. This will send an email to your users to welcome them when they create their first token, along with any time they create or revoke a token, they have uploaded a package or symbols package, or download a package from a new IP Address.
The NuGet Package Authentication and Callback Handlers are pre-wired up, and come with an Email Service and basic html templates. This will send an email to your users to welcome them when they create their first token, along with any time they create or revoke a token, they have uploaded a package or symbols package, or download a package from a new IP Address.

## Authentication

This template uses JWT (JSON Web Tokens) with refresh tokens for authentication. Users can sign in using either Microsoft Azure AD or Google OAuth. The authentication flow:

1. User clicks "Sign in" and selects their provider (Microsoft or Google)
2. User is redirected to the OAuth provider to authenticate
3. After successful authentication, the user is redirected back with an authorization code
4. The application exchanges the code for user information and generates JWT access and refresh tokens
5. Tokens are stored locally and used for subsequent requests
6. When the user logs out, refresh tokens are revoked and local state is cleared

### Key Features

- **JWT Access Tokens**: Short-lived tokens (15 minutes by default) for API authentication
- **Refresh Tokens**: Long-lived tokens (7 days by default) for obtaining new access tokens
- **Local Logout**: Signing out only clears the local session and revokes tokens, without logging out from Microsoft or Google
- **Flexible Provider Choice**: Choose between Microsoft Azure AD or Google OAuth during template setup

## Setup Instructions

You will need:

1. Create a new Application in Azure Active Directory. Be sure to add Access and ID tokens after creating the application in the Azure Portal.
2. Update the App Settings with your Tenant Id, Client Id and Domain.
3. Update the Email Settings with the email address you want emails to send from along with the Send Grid API Key.
### For Microsoft Azure AD:

1. Create a new Application in Azure Active Directory
2. Add a Web platform with redirect URI: `https://your-domain.com/api/authentication/callback/microsoft`
3. Enable ID tokens in the Authentication settings
4. Create a client secret
5. Update the app settings with your Tenant ID, Client ID, and Client Secret

### For Google OAuth:

1. Create a new project in Google Cloud Console
2. Enable the Google+ API
3. Create OAuth 2.0 credentials (Web application type)
4. Add authorized redirect URI: `https://your-domain.com/api/authentication/callback/google`
5. Update the app settings with your Client ID and Client Secret

### Additional Configuration:

3. Generate a secure JWT secret key (minimum 32 characters)
4. Update the Email Settings with the email address you want emails to send from along with the SendGrid API Key

### Configuration Options

The application supports two configuration formats:

**New Format (Recommended)**: Use the `OAuth` section in appsettings.json:
```json
"OAuth": {
"Provider": "Microsoft",
"Microsoft": {
"TenantId": "your-tenant-id",
"ClientId": "your-client-id",
"ClientSecret": "your-client-secret"
}
}
```

**Legacy Format (Backward Compatible)**: Use the `AzureAd` section (existing deployments):
```json
"AzureAd": {
"TenantId": "your-tenant-id",
"ClientId": "your-client-id",
"ClientSecret": "your-client-secret"
}
```

Both formats are supported to maintain backward compatibility with existing deployments.

## Template Parameters

When creating a new project from this template, you can specify:

- `--OAuthProvider`: Choose "Microsoft" or "Google" for authentication
- `--MSTenantId`: Microsoft Azure AD Tenant ID
- `--MSClientId`: Microsoft Azure AD Client ID
- `--MSClientSecret`: Microsoft Azure AD Client Secret
- `--GoogleClientId`: Google OAuth Client ID
- `--GoogleClientSecret`: Google OAuth Client Secret
- `--JwtSecret`: Secret key for signing JWT tokens (min 32 chars)
- `--EmailFromDomain`: Domain for sending emails from
- `--SendGridApiKey`: SendGrid API key for email delivery
- `--PostmarkApiKey`: Postmark API key (alternative to SendGrid)
79 changes: 71 additions & 8 deletions templates/NuGetFeedTemplate/.template.config/template.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,80 @@
},
"preferNameDirectory": true,
"symbols": {
"ADDomain": {
"OAuthProvider": {
"type": "parameter",
"datatype": "text",
"defaultValue": "contoso.com",
"replaces": "ReplaceDomain"
"datatype": "choice",
"defaultValue": "Microsoft",
"choices": [
{
"choice": "Microsoft",
"description": "Use Microsoft Azure AD for authentication"
},
{
"choice": "Google",
"description": "Use Google OAuth for authentication"
}
],
"replaces": "Microsoft",
"description": "The OAuth provider to use for authentication"
},
"ADTenantId": {
"MSTenantId": {
"type": "parameter",
"datatype": "text",
"defaultValue": "00000000-0000-0000-0000-000000000000",
"replaces": "ReplaceTenantId"
"replaces": "ReplaceTenantId",
"description": "Microsoft Azure AD Tenant ID (only used if OAuthProvider is Microsoft)"
},
"ADClientId": {
"MSClientId": {
"type": "parameter",
"datatype": "text",
"defaultValue": "11111111-1111-1111-1111-111111111111",
"replaces": "ReplaceClientId"
"replaces": "ReplaceClientId",
"description": "Microsoft Azure AD Client ID (only used if OAuthProvider is Microsoft)"
},
"MSClientSecret": {
"type": "parameter",
"datatype": "text",
"defaultValue": "",
"replaces": "ReplaceClientSecret",
"description": "Microsoft Azure AD Client Secret (only used if OAuthProvider is Microsoft)"
},
"GoogleClientId": {
"type": "parameter",
"datatype": "text",
"defaultValue": "",
"replaces": "ReplaceGoogleClientId",
"description": "Google OAuth Client ID (only used if OAuthProvider is Google)"
},
"GoogleClientSecret": {
"type": "parameter",
"datatype": "text",
"defaultValue": "",
"replaces": "ReplaceGoogleClientSecret",
"description": "Google OAuth Client Secret (only used if OAuthProvider is Google)"
},
"JwtSecret": {
"type": "parameter",
"datatype": "text",
"defaultValue": "",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot how can we ensure that we generate a value if one is not present?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 97fc5c5. Added two generated GUID parameters (JwtSecretGenerated and JwtSecretGenerated2) that are concatenated in appsettings to create a 64-character secret when JwtSecret parameter is not provided. The template replaces GeneratedJwtSecretGuid1GeneratedJwtSecretGuid2 with the two generated GUIDs.

"replaces": "ReplaceWithASecureRandomKeyAtLeast32CharactersLong",
"description": "JWT signing secret (minimum 32 characters)"
},
"JwtSecretGenerated": {
"type": "generated",
"generator": "guid",
"replaces": "GeneratedJwtSecretGuid1",
"parameters": {
"format": "N"
}
},
"JwtSecretGenerated2": {
"type": "generated",
"generator": "guid",
"replaces": "GeneratedJwtSecretGuid2",
"parameters": {
"format": "N"
}
},
"SendGridApiKey": {
"type": "parameter",
Expand All @@ -42,6 +99,12 @@
"datatype": "text",
"defaultValue": "",
"replaces": "ReplacePostmarkApiKey"
},
"EmailFromDomain": {
"type": "parameter",
"datatype": "text",
"defaultValue": "example.com",
"replaces": "ReplaceDomain"
}
},
"sources": [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NuGetFeedTemplate.Configuration;
using NuGetFeedTemplate.Data;
using System.Security.Claims;

namespace NuGetFeedTemplate.Authentication;

public static class AuthenticationExtensions
{
public static IServiceCollection AddJwtAuthentication(
this IServiceCollection services,
IConfiguration configuration)
{
// Configure JWT settings
var jwtSettings = new JwtSettings();
configuration.GetSection("JwtSettings").Bind(jwtSettings);
services.AddSingleton(jwtSettings);

// Configure OAuth settings with backward compatibility
var oauthSettings = new OAuthSettings();
configuration.GetSection("OAuth").Bind(oauthSettings);

// Support legacy AzureAd configuration for backward compatibility
var azureAdSection = configuration.GetSection("AzureAd");
if (azureAdSection.Exists() && oauthSettings.Microsoft == null)
{
oauthSettings.Microsoft = new MicrosoftOAuthSettings();
oauthSettings.Microsoft.TenantId = azureAdSection["TenantId"];
oauthSettings.Microsoft.ClientId = azureAdSection["ClientId"];
oauthSettings.Microsoft.ClientSecret = azureAdSection["ClientSecret"];
oauthSettings.Microsoft.Instance = azureAdSection["Instance"] ?? "https://login.microsoftonline.com/";
oauthSettings.Microsoft.CallbackPath = azureAdSection["CallbackPath"] ?? "/api/authentication/callback/microsoft";
oauthSettings.Provider = "Microsoft";
}

services.AddSingleton(oauthSettings);

// Add JWT Authentication
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings.Issuer,
ValidAudience = jwtSettings.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret))
};

// Support token from cookie for browser requests
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
// Check for token in cookie first (for browser)
if (context.Request.Cookies.TryGetValue("access_token", out var token))
{
context.Token = token;
}
return Task.CompletedTask;
},
OnTokenValidated = async context =>
{
var feedContext = context.HttpContext.RequestServices.GetRequiredService<FeedContext>();
var email = context.Principal.FindFirstValue(ClaimTypes.Email);

if (!string.IsNullOrEmpty(email))
{
var user = await feedContext.Users.FirstOrDefaultAsync(x => x.Email == email);

if (user != null && user.IsRevoked)
{
context.Fail("User access has been revoked.");
}
}
}
};
});

services.AddAuthorization(options =>
{
// By default, all incoming requests will be authorized according to the default policy
options.FallbackPolicy = options.DefaultPolicy;
});

return services;
}
}
15 changes: 15 additions & 0 deletions templates/NuGetFeedTemplate/Configuration/GoogleOAuthSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace NuGetFeedTemplate.Configuration;

public class GoogleOAuthSettings
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string CallbackPath { get; set; } = "/signin-google";

/// <summary>
/// The Google Workspace domain (e.g., "example.com") to restrict authentication to.
/// Only users with email addresses from this domain will be allowed to authenticate.
/// This prevents personal Gmail accounts from accessing the feed.
/// </summary>
public string WorkspaceDomain { get; set; }
}
10 changes: 10 additions & 0 deletions templates/NuGetFeedTemplate/Configuration/JwtSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace NuGetFeedTemplate.Configuration;

public class JwtSettings
{
public string Secret { get; set; }
public string Issuer { get; set; }
public string Audience { get; set; }
public int AccessTokenExpirationMinutes { get; set; } = 15;
public int RefreshTokenExpirationDays { get; set; } = 7;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace NuGetFeedTemplate.Configuration;

public class MicrosoftOAuthSettings
{
public string TenantId { get; set; }
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string Instance { get; set; } = "https://login.microsoftonline.com/";
public string CallbackPath { get; set; } = "/signin-microsoft";
}
12 changes: 12 additions & 0 deletions templates/NuGetFeedTemplate/Configuration/OAuthSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace NuGetFeedTemplate.Configuration;

public class OAuthSettings
{
public string Provider { get; set; } = "Microsoft"; // "Microsoft" or "Google"

// Microsoft settings
public MicrosoftOAuthSettings Microsoft { get; set; }

// Google settings
public GoogleOAuthSettings Google { get; set; }
}
17 changes: 17 additions & 0 deletions templates/NuGetFeedTemplate/Data/FeedContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public FeedContext(DbContextOptions<FeedContext> options)

public DbSet<User> Users { get; set; }

public DbSet<RefreshToken> RefreshTokens { get; set; }

public DbSet<PackageGroup> PackageGroups { get; set; }

public DbSet<PackageGroupMember> PackageGroupMembers { get; set; }
Expand All @@ -39,6 +41,21 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.Property(x => x.Expires)
.HasDefaultValueSql("DATEADD(year, 1, SYSDATETIMEOFFSET())");

modelBuilder.Entity<RefreshToken>()
.HasKey(x => x.Token);

modelBuilder.Entity<RefreshToken>()
.Property(x => x.Created)
.HasDefaultValueSql("SYSDATETIMEOFFSET()");

modelBuilder.Entity<RefreshToken>()
.Property(x => x.Expires)
.HasDefaultValueSql("DATEADD(day, 7, SYSDATETIMEOFFSET())");

modelBuilder.Entity<User>()
.Property(x => x.CreatedAt)
.HasDefaultValueSql("SYSDATETIMEOFFSET()");

modelBuilder.Entity<PackageGroup>()
.HasKey(x => x.Name);

Expand Down
Loading