Skip to content

Replace Microsoft Identity with JWT/Refresh Token authentication supporting Google OAuth with domain restrictions#41

Draft
Copilot wants to merge 8 commits intomasterfrom
copilot/update-authentication-jwt-refresh-token
Draft

Replace Microsoft Identity with JWT/Refresh Token authentication supporting Google OAuth with domain restrictions#41
Copilot wants to merge 8 commits intomasterfrom
copilot/update-authentication-jwt-refresh-token

Conversation

Copy link
Contributor

Copilot AI commented Nov 16, 2025

JWT/Refresh Token Authentication Implementation - COMPLETE ✅

This PR successfully implements JWT-based authentication with refresh tokens to replace the Microsoft Identity integration, addressing the issue where logout would attempt to log users out of their Microsoft account.

✅ All Changes Completed

  • Created RefreshToken model for managing refresh tokens with session tracking
  • Updated User model with FirstName, LastName (non-nullable), ProfilePictureUrl, CreatedAt, and LastLoginAt fields
  • Split OAuth configuration classes into separate files (one class per file)
  • Created authentication extension method to reduce Program.cs bloat
  • Added auto-generated JWT secret using GUIDs when not provided
  • RefreshToken now uses GUID as primary key with separate Token field
  • Added session tracking: UserAgent and DeviceInfo fields to RefreshToken
  • Fixed GraphProfilePhotoService to use configured OAuth provider
  • Created JWT token generation and validation service (JwtTokenService)
  • Refactored authentication to use minimal APIs instead of controllers
  • Implemented Google OAuth provider support alongside Microsoft
  • Added Google Workspace domain restriction to prevent personal Gmail accounts
  • Added backward compatibility for legacy AzureAd configuration
  • Consolidated all database changes into single migration with CreatedAt backfilling
  • Updated Program.cs to configure JWT authentication with minimal API endpoints
  • Removed Microsoft.Identity.Web dependencies from project file
  • Updated UI pages to use new authentication endpoints
  • Updated template configuration for OAuth provider selection
  • Updated appsettings.Template.json with new authentication configuration
  • Updated ReadMe.md with new setup instructions
  • Updated GraphProfilePhotoService to fetch from OAuth providers with Gravatar fallback and caching
  • Added auth-callback page for handling OAuth redirects
  • Updated _LoginPartial.cshtml with new sign-in/sign-out flow
  • Added client-side token refresh interceptor (auth.js)
  • Updated all pages to use ClaimTypes.Email and ClaimTypes.Name
  • Added AllowAnonymous attributes to public pages
  • Updated Profile/Icon page to work with JWT authentication
  • Security scan completed - no vulnerabilities found

🎯 Key Features Implemented

  1. Minimal API Endpoints: Authentication endpoints now use minimal APIs for cleaner, more modern code
  2. OAuthService: Extracted OAuth logic into a dedicated service with proper claim extraction (FirstName, LastName, Email)
  3. JWT Access Tokens: Short-lived tokens (15 minutes by default) for API authentication
  4. Refresh Tokens: Long-lived tokens (7 days by default) for obtaining new access tokens
  5. Session Management:
    • Each refresh token has a unique GUID identifier
    • Tracks UserAgent and DeviceInfo for each session
    • Allows users to view and revoke active sessions
  6. Automatic Token Refresh: Client-side JavaScript automatically refreshes tokens before expiration
  7. Local Logout: Signing out only clears the local session and revokes tokens - does NOT log out from Microsoft/Google
  8. Flexible Provider: Choose between Microsoft Azure AD or Google OAuth during template setup
  9. Tenant/Domain Restrictions:
    • Microsoft: Restricted to specific Azure AD tenant (prevents random Microsoft accounts from accessing the feed)
    • Google: Restricted to specific Workspace domain via WorkspaceDomain setting (prevents personal Gmail accounts like user@gmail.com, only allows business accounts like user@company.com)
    • Email verification required for Google accounts
  10. Smart Profile Picture Management:
  • Fetches profile pictures from configured OAuth provider (Microsoft Graph API or Google)
  • Falls back to Gravatar if provider doesn't have a profile picture
  • Caches all profile pictures as {email}.jpg in wwwroot/profile-cache/ for performance
  • Admins can view cached profile pictures of all previously logged-in users
  1. Record-Based Models: Authentication request/response models use immutable records following best practices
  2. Backward Compatibility: Supports legacy AzureAd configuration for existing deployments without breaking changes
  3. Single Consolidated Migration: All database schema changes in one migration with intelligent CreatedAt backfilling from historical AuthToken data
  4. Auto-Generated JWT Secret: If no JWT secret is provided, one is automatically generated using two concatenated GUIDs (64 characters)
  5. Clean Architecture: Authentication configuration extracted into extension method for cleaner Program.cs

🏗️ Architecture Changes

Authentication Endpoints (Minimal APIs):

  • GET /api/authentication/login/{provider} - Initiates OAuth flow
  • GET /api/authentication/callback/{provider} - OAuth callback handler
  • POST /api/authentication/refresh - Refresh access token
  • POST /api/authentication/logout - Logout and revoke tokens

Services:

  • IOAuthService - Handles OAuth provider interactions with proper claim extraction and domain validation
  • IJwtTokenService - Manages JWT token lifecycle with session tracking
  • IGraphProfilePhotoService - Manages profile pictures with provider-first approach and caching

Configuration Classes (One class per file):

  • OAuthSettings.cs
  • MicrosoftOAuthSettings.cs
  • GoogleOAuthSettings.cs
  • JwtSettings.cs

Data Models (One type per file using records):

  • LoginRequest.cs
  • LoginResponse.cs
  • RefreshTokenRequest.cs
  • ExternalAuthCallbackRequest.cs
  • OAuthUserInfo - Structured OAuth data (Email, FirstName, LastName, DisplayName, ProfilePictureUrl)

Database Migration:

  • Single migration 20251116152100_AddJwtAuthenticationSupport.cs includes:
    • RefreshTokens table creation with:
      • GUID primary key (Id)
      • Unique token field
      • Session tracking (UserAgent, DeviceInfo)
      • IP tracking (CreatedByIp, RevokedByIp - nullable)
    • User table additions: FirstName (non-nullable), LastName (non-nullable), ProfilePictureUrl, CreatedAt, LastLoginAt
    • SQL script to backfill CreatedAt from earliest AuthToken for each user

🔄 Backward Compatibility

Configuration Support: The application now supports both configuration formats to ensure existing deployments continue to work:

New Format (Recommended):

"OAuth": {
  "Provider": "Microsoft",
  "Microsoft": {
    "TenantId": "your-tenant-id",
    "ClientId": "your-client-id",
    "ClientSecret": "your-client-secret"
  },
  "Google": {
    "ClientId": "your-client-id",
    "ClientSecret": "your-client-secret",
    "WorkspaceDomain": "your-company.com"
  }
}

Legacy Format (Existing Deployments):

"AzureAd": {
  "TenantId": "your-tenant-id",
  "ClientId": "your-client-id",
  "ClientSecret": "your-client-secret"
}

Both formats are supported to ensure existing deployments continue to work without breaking changes. The legacy AzureAd section is automatically mapped to the new OAuth.Microsoft settings if present, maintaining full backward compatibility for existing tenants deploying from source.

🔐 Security Features

  • Cryptographically secure token generation using RandomNumberGenerator
  • Token expiration and validation
  • Refresh token revocation on logout
  • IP address tracking for token creation and revocation
  • Session tracking with UserAgent and DeviceInfo
  • User revocation support
  • All tokens are properly validated before use
  • Microsoft tenant restriction: Only users from specified Azure AD tenant can authenticate
  • Google domain restriction: Only users from specified Workspace domain can authenticate (blocks personal gmail.com accounts)
  • Email verification check: Google users must have verified email addresses
  • Auto-generated secure JWT secret: 64-character random secret if not provided

📝 Setup Instructions

Users can now choose their OAuth provider when creating a new project:

# For Microsoft Azure AD (tenant-restricted)
dotnet new packagefeed -n MyFeed --OAuthProvider Microsoft --MSTenantId "your-tenant-id" --MSClientId "your-client-id" --MSClientSecret "your-secret"

# For Google Workspace (domain-restricted)
dotnet new packagefeed -n MyFeed --OAuthProvider Google --GoogleClientId "your-client-id" --GoogleClientSecret "your-secret" --EmailFromDomain "your-company.com"

# JWT secret is auto-generated if not provided, or you can specify:
# --JwtSecret "your-secure-jwt-secret-min-32-chars"

Note: The EmailFromDomain parameter is used as the Google WorkspaceDomain to restrict authentication to that domain.

✅ Testing Recommendations

When testing this implementation:

  1. Microsoft OAuth Flow: Verify only users from specified tenant can authenticate
  2. Google OAuth Flow: Verify only users from specified Workspace domain can authenticate (personal gmail.com accounts are blocked)
  3. Token Refresh: Wait for access token to expire and verify automatic refresh
  4. Logout Behavior: Confirm logout doesn't redirect to Microsoft/Google logout
  5. Package Publishing: Test NuGet package operations with JWT authentication
  6. User Management: Verify admin functions work with new authentication
  7. Profile Pictures: Verify provider images are fetched first, Gravatar fallback works, and caching functions correctly
  8. Backward Compatibility: Verify existing deployments with AzureAd configuration continue to work
  9. Migration: Verify CreatedAt is properly backfilled from earliest AuthToken for each user
  10. Session Tracking: Verify UserAgent and DeviceInfo are captured for new sessions

🔍 Security Scan Results

CodeQL security analysis completed with 0 vulnerabilities found in both C# and JavaScript code.


Implementation is complete with session tracking, auto-generated secrets, clean architecture, and ready for testing! 🎉

Original prompt

This section details on the original issue you should resolve

<issue_title>Update Authentication to use JWT / Refresh Token</issue_title>
<issue_description># Description

Currently we use Microsoft for Authentication and when logging out this actually tries to log us out of our Microsoft Account rather than just logging us out of THIS SITE.

Enhancements

  • Update the Authentication so that we can optionally configure the use of Google INSTEAD OF Microsoft
  • Update this so that we are using a JWT with a Refresh Token for authentication. This should follow industry standards for lifetimes and flow.
  • When the user logs out it should revoke the Refresh Token and clear OUR authenticated state rather than again reaching out to either Microsoft or Google.

</issue_description>

Comments on the Issue (you are @copilot in this section)


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Co-authored-by: dansiegel <3860573+dansiegel@users.noreply.github.com>
Copy link
Member

@dansiegel dansiegel left a comment

Choose a reason for hiding this comment

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

@copilot rework this to use minimal APIs

Co-authored-by: dansiegel <3860573+dansiegel@users.noreply.github.com>
Copilot AI changed the title [WIP] Update authentication to use JWT and refresh token Replace Microsoft Identity with JWT/Refresh Token authentication supporting Google OAuth Nov 16, 2025
Copilot AI requested a review from dansiegel November 16, 2025 15:41
Co-authored-by: dansiegel <3860573+dansiegel@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Nov 16, 2025

@copilot rework this to use minimal APIs

Done! Refactored authentication to use minimal APIs. Changes in commit f729357:

  • Removed AuthenticationController.cs
  • Created OAuthService to encapsulate OAuth provider logic
  • Moved all authentication endpoints to minimal APIs in Program.cs:
    • GET /api/authentication/login/{provider} - Initiate OAuth flow
    • GET /api/authentication/callback/{provider} - OAuth callback handler
    • POST /api/authentication/refresh - Refresh access token
    • POST /api/authentication/logout - Logout and revoke tokens

The endpoints use the same routes and maintain identical functionality while using the cleaner minimal API syntax.

Copy link
Member

@dansiegel dansiegel left a comment

Choose a reason for hiding this comment

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

@copilot whether we authenticate with Microsoft or Google we should use the profile picture from that provider. If they do not have one then we might want to fallback to Gravatar, but we should not rely on this exclusively. Also be sure that we are always grabbing the correct claims for:

  • First Name
  • Last Name
  • Email

We might also want to cache the profile image using the {email}.jpg so that the admin can view the appropriate profile image of any users which have previously logged in.

@@ -0,0 +1,28 @@
namespace NuGetFeedTemplate.Models;

public class LoginRequest
Copy link
Member

Choose a reason for hiding this comment

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

@copilot only put one class or type in a file. The name of the type should match the name of the file. If making a Generic<T> type the type name should be like Generic{T}. Also prefer the use of Records over classes where it makes sense. For instance we cannot use them for options classes typically but this make far more sense for API request and response types since these should be immutable.

Be sure to update your instructions so you remember this.

Copy link
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 c1c9a0f:

  • Split AuthenticationModels.cs into separate files:
    • LoginRequest.cs
    • LoginResponse.cs
    • RefreshTokenRequest.cs
    • ExternalAuthCallbackRequest.cs
  • Converted all to immutable records instead of classes
  • Created OAuthUserInfo record for OAuth data structure

Each file now contains only one type that matches the file name.

…ofile handling

Co-authored-by: dansiegel <3860573+dansiegel@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Nov 16, 2025

@copilot whether we authenticate with Microsoft or Google we should use the profile picture from that provider. If they do not have one then we might want to fallback to Gravatar, but we should not re...

Implemented in commit c1c9a0f:

Profile Picture Handling:

  • Now fetches profile pictures from OAuth providers (Microsoft Graph API for Microsoft, direct URL for Google)
  • Falls back to Gravatar if provider doesn't have a profile picture
  • Caches all profile pictures as {email}.jpg in wwwroot/profile-cache/ for faster loading
  • Admins can view cached profile pictures of all users who have previously logged in

Enhanced Claims Extraction:

  • Microsoft: Extracts givenName, surname, mail/userPrincipalName, displayName, and profile photo URL
  • Google: Extracts given_name, family_name, email, name, and picture URL
  • User model now stores FirstName, LastName, Email, and ProfilePictureUrl separately
  • Created new OAuthUserInfo record to properly structure OAuth data

Copy link
Member

@dansiegel dansiegel left a comment

Choose a reason for hiding this comment

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

@copilot avoid a complete breaking change to the Auth Configuration for Microsoft. We have existing tenants that deploy this from source. Making this change will break those feeds.

Co-authored-by: dansiegel <3860573+dansiegel@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Nov 16, 2025

@copilot avoid a complete breaking change to the Auth Configuration for Microsoft. We have existing tenants that deploy this from source. Making this change will break those feeds.

Fixed in commit 9b4de60:

Backward Compatibility Maintained:

  • Added support for legacy AzureAd configuration section
  • Program.cs now checks for AzureAd section and automatically maps it to OAuth.Microsoft settings
  • Both configuration formats are documented in appsettings.Template.json with comments
  • Updated ReadMe.md to explain both configuration options

Existing deployments using the AzureAd section will continue to work without any changes. The new OAuth section is recommended for new deployments but is not required.

Copy link
Member

@dansiegel dansiegel left a comment

Choose a reason for hiding this comment

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

@copilot this needs several changes still:

  1. You should notice that as part of our Microsoft Authentication we are specifically tied to one tenant. This prevents random Microsoft Accounts from logging in and ensures that only those that should have access do have access. We need to do something similar with Google. We want to exclude things like user@gmail.com and make sure that we are only authenticating Google Business accounts user@customdomain.com and we need to ensure that the user is actually part of that tenant on Google.
  2. Limit your Pull Request to no more than ONE Db Migration. Be sure to update your instructions to ensure that you remember this as well.

public bool IsRevoked { get; set; }

// For local authentication
public string PasswordHash { get; set; }
Copy link
Member

Choose a reason for hiding this comment

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

@copilot we aren't supporting local logins. This goes beyond the scope of what we are trying to do.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed PasswordHash field in commit 9bdc9ea. Local authentication is not supported.

Comment on lines +20 to +21
public string ExternalProvider { get; set; } // "Microsoft" or "Google"
public string ExternalId { get; set; }
Copy link
Member

Choose a reason for hiding this comment

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

@copilot we really don't care about this. We only care that the user was properly authenticated. When the application is running we only care that the user was authenticated.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed ExternalProvider and ExternalId fields in commit 9bdc9ea. We only track that the user was authenticated.

public string ExternalProvider { get; set; } // "Microsoft" or "Google"
public string ExternalId { get; set; }

public DateTimeOffset CreatedAt { get; set; }
Copy link
Member

Choose a reason for hiding this comment

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

@copilot update the migration if we're going to track this we should backfill based on the earliest AuthToken that was created since this will be tied to a user and it has a Created Date.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated migration in commit 9bdc9ea to backfill CreatedAt:

UPDATE u
SET u.CreatedAt = ISNULL(
    (SELECT MIN(at.Created) 
     FROM AuthTokens at 
     WHERE at.UserEmail = u.Email),
    SYSDATETIMEOFFSET()
)
FROM Users u

Uses earliest AuthToken creation date for each user, falls back to current time if no tokens exist.

…e domain restriction

Co-authored-by: dansiegel <3860573+dansiegel@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Nov 16, 2025

@copilot this needs several changes still:

  1. You should notice that as part of our Microsoft Authentication we are specifically tied to one tenant. This prevents random Microsoft Accounts from loggi...

Fixed in commit 9bdc9ea:

1. Domain/Tenant Restrictions:

  • Microsoft: Already tenant-restricted via TenantId in OAuth configuration
  • Google: Added WorkspaceDomain setting to restrict to specific domain (e.g., "company.com")
    • Validates email domain matches configured WorkspaceDomain
    • Blocks personal Gmail accounts (user@gmail.com)
    • Requires verified email addresses

2. Single Migration:

  • Deleted 20251116160600_AddUserProfileFields.cs
  • Consolidated all changes into 20251116152100_AddJwtAuthenticationSupport.cs
  • Added SQL to backfill CreatedAt from earliest AuthToken per user

Copilot AI changed the title Replace Microsoft Identity with JWT/Refresh Token authentication supporting Google OAuth Replace Microsoft Identity with JWT/Refresh Token authentication supporting Google OAuth with domain restrictions Nov 16, 2025
Copilot AI requested a review from dansiegel November 16, 2025 22:46
"JwtSecret": {
"type": "parameter",
"datatype": "text",
"defaultValue": "",
Copy link
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
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.

public GoogleOAuthSettings Google { get; set; }
}

public class MicrosoftOAuthSettings
Copy link
Member

Choose a reason for hiding this comment

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

@copilot one class per file. Be sure to update your instructions to prevent this error from occurring again.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Split into separate files in commit 97fc5c5:

  • OAuthSettings.cs
  • MicrosoftOAuthSettings.cs
  • GoogleOAuthSettings.cs

Each file contains only one class.


public DateTimeOffset? RevokedAt { get; set; }

public string RevokedByIp { get; set; }
Copy link
Member

Choose a reason for hiding this comment

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

@copilot shouldn't this be nullable since a valid token wouldn't be revoked and there would be no IP address here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed RevokedByIp to nullable in commit 97fc5c5. Valid tokens won't have a revocation IP address.


namespace NuGetFeedTemplate.Data.Models;

public class RefreshToken
Copy link
Member

Choose a reason for hiding this comment

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

@copilot We should get additional information so that we could display active sessions and allow users to revoke sessions on other machines. This could also present an issue with security since we would need to reference the Token without directly exposing the Refresh Tokens.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Enhanced RefreshToken model in commit 97fc5c5:

  • Changed primary key from Token to GUID Id
  • Token is now a unique indexed field
  • Added UserAgent field (500 chars) to track browser/client
  • Added DeviceInfo field (200 chars) for device identification
  • Allows displaying active sessions and revoking specific sessions without exposing actual refresh tokens

Comment on lines +9 to +10
public string FirstName { get; set; }
public string LastName { get; set; }
Copy link
Member

Choose a reason for hiding this comment

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

@copilot While the Database considers this nullable this doesn't work well in a nullable context.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Made FirstName and LastName non-nullable with empty string defaults in commit 97fc5c5. This works better in nullable contexts while keeping database columns nullable.

builder.Services.AddSingleton(jwtSettings);

// Configure OAuth settings with backward compatibility
var oauthSettings = new OAuthSettings();
Copy link
Member

Choose a reason for hiding this comment

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

@copilot create an extensions class that we can use to configure the Authentication and reduce the code bloat in the Program.cs

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Created AuthenticationExtensions.cs in commit 97fc5c5 with AddJwtAuthentication() extension method. This reduces Program.cs bloat by moving all authentication configuration logic into a dedicated extension.

return null;
}
// This method is deprecated for JWT authentication
// Profile photos should be fetched using the user's email
Copy link
Member

Choose a reason for hiding this comment

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

@copilot this is WRONG. When using Microsoft Authentication we SHOULD be using the Graph to grab the user's profile image. When we use Google we should get the user's profile image from them. This needs to be dynamic based on the configured Authentication Provider.

Copy link
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. GraphProfilePhotoService now uses _oauthSettings.Provider (configured OAuth provider) instead of non-existent user.ExternalProvider field. This correctly fetches profile pictures from Microsoft Graph API when using Microsoft authentication, or from Google's picture URL when using Google.

…ing, fix profile service

Co-authored-by: dansiegel <3860573+dansiegel@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Update Authentication to use JWT / Refresh Token

2 participants