Skip to content

Commit 18e3b02

Browse files
authored
Add API Key Authentication for Copilot (#328)
1 parent 30dbfb6 commit 18e3b02

File tree

14 files changed

+860
-253
lines changed

14 files changed

+860
-253
lines changed

src/Modules/CrestApps.OrchardCore.AI.Chat.Copilot/Drivers/AIProfileCopilotDisplayDriver.cs

Lines changed: 50 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using CrestApps.OrchardCore.AI.Chat.Copilot.Models;
22
using CrestApps.OrchardCore.AI.Chat.Copilot.Services;
3+
using CrestApps.OrchardCore.AI.Chat.Copilot.Settings;
34
using CrestApps.OrchardCore.AI.Chat.Copilot.ViewModels;
45
using CrestApps.OrchardCore.AI.Models;
56
using Microsoft.AspNetCore.Http;
@@ -9,6 +10,7 @@
910
using OrchardCore.DisplayManagement.Handlers;
1011
using OrchardCore.DisplayManagement.Views;
1112
using OrchardCore.Entities;
13+
using OrchardCore.Settings;
1214
using USR = OrchardCore.Users;
1315

1416
namespace CrestApps.OrchardCore.AI.Chat.Copilot.Drivers;
@@ -18,18 +20,21 @@ internal sealed class AIProfileCopilotDisplayDriver : DisplayDriver<AIProfile>
1820
private readonly GitHubOAuthService _oauthService;
1921
private readonly UserManager<USR.IUser> _userManager;
2022
private readonly IHttpContextAccessor _httpContextAccessor;
23+
private readonly ISiteService _siteService;
2124

2225
internal readonly IStringLocalizer S;
2326

2427
public AIProfileCopilotDisplayDriver(
2528
GitHubOAuthService oauthService,
2629
UserManager<USR.IUser> userManager,
2730
IHttpContextAccessor httpContextAccessor,
31+
ISiteService siteService,
2832
IStringLocalizer<AIProfileCopilotDisplayDriver> stringLocalizer)
2933
{
3034
_oauthService = oauthService;
3135
_userManager = userManager;
3236
_httpContextAccessor = httpContextAccessor;
37+
_siteService = siteService;
3338
S = stringLocalizer;
3439
}
3540

@@ -42,29 +47,40 @@ public override IDisplayResult Edit(AIProfile profile, BuildEditorContext contex
4247
model.CopilotModel = copilotSettings.CopilotModel;
4348
model.IsAllowAll = copilotSettings.IsAllowAll;
4449

45-
// Check if current user has authenticated with GitHub
46-
var user = await _userManager.GetUserAsync(_httpContextAccessor.HttpContext?.User);
47-
if (user != null)
50+
// Load site-level settings to determine auth mode.
51+
var siteSettings = await _siteService.GetSettingsAsync<CopilotSettings>();
52+
model.AuthenticationType = siteSettings.AuthenticationType;
53+
54+
if (siteSettings.AuthenticationType == CopilotAuthenticationType.ApiKey)
55+
{
56+
// BYOK mode — no GitHub auth needed; model is a text input.
57+
model.AvailableModels = [];
58+
}
59+
else
4860
{
49-
var userId = await _userManager.GetUserIdAsync(user);
50-
model.IsAuthenticated = await _oauthService.IsAuthenticatedAsync(userId);
51-
if (model.IsAuthenticated)
61+
// GitHub OAuth mode — check auth and load models from GitHub API.
62+
var user = await _userManager.GetUserAsync(_httpContextAccessor.HttpContext?.User);
63+
if (user != null)
5264
{
53-
var credential = await _oauthService.GetCredentialAsync(userId);
54-
model.GitHubUsername = credential?.GitHubUsername;
55-
56-
// Load available models dynamically from GitHub API.
57-
var models = await _oauthService.ListModelsAsync(userId);
58-
if (models.Count > 0)
65+
var userId = await _userManager.GetUserIdAsync(user);
66+
model.IsAuthenticated = await _oauthService.IsAuthenticatedAsync(userId);
67+
if (model.IsAuthenticated)
5968
{
60-
model.AvailableModels = models
61-
.Select(m => new SelectListItem(m.Name, m.Id))
62-
.ToList();
69+
var credential = await _oauthService.GetCredentialAsync(userId);
70+
model.GitHubUsername = credential?.GitHubUsername;
71+
72+
var models = await _oauthService.ListModelsAsync(userId);
73+
if (models.Count > 0)
74+
{
75+
model.AvailableModels = models
76+
.Select(m => new SelectListItem(m.Name, m.Id))
77+
.ToList();
78+
}
6379
}
6480
}
65-
}
6681

67-
model.AvailableModels ??= [];
82+
model.AvailableModels ??= [];
83+
}
6884
}).Location("Content:3.5");
6985
}
7086

@@ -83,20 +99,25 @@ public override async Task<IDisplayResult> UpdateAsync(AIProfile profile, Update
8399
IsAllowAll = model.IsAllowAll,
84100
};
85101

86-
// Copy the current user's GitHub credential to the profile so
87-
// any chat session using this profile can reuse the token.
88-
var user = await _userManager.GetUserAsync(_httpContextAccessor.HttpContext?.User);
89-
if (user is not null)
90-
{
91-
var userId = await _userManager.GetUserIdAsync(user);
92-
var credentials = await _oauthService.GetProtectedCredentialsAsync(userId);
102+
var siteSettings = await _siteService.GetSettingsAsync<CopilotSettings>();
93103

94-
if (credentials is not null)
104+
if (siteSettings.AuthenticationType == CopilotAuthenticationType.GitHubOAuth)
105+
{
106+
// Copy the current user's GitHub credential to the profile so
107+
// any chat session using this profile can reuse the token.
108+
var user = await _userManager.GetUserAsync(_httpContextAccessor.HttpContext?.User);
109+
if (user is not null)
95110
{
96-
copilotSettings.GitHubUsername = credentials.GitHubUsername;
97-
copilotSettings.ProtectedAccessToken = credentials.ProtectedAccessToken;
98-
copilotSettings.ProtectedRefreshToken = credentials.ProtectedRefreshToken;
99-
copilotSettings.ExpiresAt = credentials.ExpiresAt;
111+
var userId = await _userManager.GetUserIdAsync(user);
112+
var credentials = await _oauthService.GetProtectedCredentialsAsync(userId);
113+
114+
if (credentials is not null)
115+
{
116+
copilotSettings.GitHubUsername = credentials.GitHubUsername;
117+
copilotSettings.ProtectedAccessToken = credentials.ProtectedAccessToken;
118+
copilotSettings.ProtectedRefreshToken = credentials.ProtectedRefreshToken;
119+
copilotSettings.ExpiresAt = credentials.ExpiresAt;
120+
}
100121
}
101122
}
102123

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using CrestApps.OrchardCore.AI.Chat.Copilot.Models;
22
using CrestApps.OrchardCore.AI.Chat.Copilot.Services;
3+
using CrestApps.OrchardCore.AI.Chat.Copilot.Settings;
34
using CrestApps.OrchardCore.AI.Chat.Copilot.ViewModels;
45
using CrestApps.OrchardCore.AI.Models;
56
using Microsoft.AspNetCore.Http;
@@ -9,6 +10,7 @@
910
using OrchardCore.DisplayManagement.Handlers;
1011
using OrchardCore.DisplayManagement.Views;
1112
using OrchardCore.Entities;
13+
using OrchardCore.Settings;
1214
using OrchardCore.Users;
1315

1416
namespace CrestApps.OrchardCore.AI.Chat.Copilot.Drivers;
@@ -18,18 +20,21 @@ internal sealed class ChatInteractionCopilotDisplayDriver : DisplayDriver<ChatIn
1820
private readonly GitHubOAuthService _oauthService;
1921
private readonly UserManager<IUser> _userManager;
2022
private readonly IHttpContextAccessor _httpContextAccessor;
23+
private readonly ISiteService _siteService;
2124

2225
internal readonly IStringLocalizer S;
2326

2427
public ChatInteractionCopilotDisplayDriver(
2528
GitHubOAuthService oauthService,
2629
UserManager<IUser> userManager,
2730
IHttpContextAccessor httpContextAccessor,
31+
ISiteService siteService,
2832
IStringLocalizer<ChatInteractionCopilotDisplayDriver> stringLocalizer)
2933
{
3034
_oauthService = oauthService;
3135
_userManager = userManager;
3236
_httpContextAccessor = httpContextAccessor;
37+
_siteService = siteService;
3338
S = stringLocalizer;
3439
}
3540

@@ -42,33 +47,45 @@ public override IDisplayResult Edit(ChatInteraction interaction, BuildEditorCont
4247
model.CopilotModel = copilotSettings.CopilotModel;
4348
model.IsAllowAll = copilotSettings.IsAllowAll;
4449

45-
// Only fetch auth/models when the orchestrator is actually Copilot.
46-
if (string.Equals(interaction.OrchestratorName, CopilotOrchestrator.OrchestratorName, StringComparison.OrdinalIgnoreCase) &&
47-
_httpContextAccessor.HttpContext?.User is not null)
48-
{
49-
var user = await _userManager.GetUserAsync(_httpContextAccessor.HttpContext.User);
50+
// Load site-level settings to determine auth mode.
51+
var siteSettings = await _siteService.GetSettingsAsync<CopilotSettings>();
52+
model.AuthenticationType = siteSettings.AuthenticationType;
5053

51-
if (user is not null)
54+
if (siteSettings.AuthenticationType == CopilotAuthenticationType.ApiKey)
55+
{
56+
// BYOK mode — no GitHub auth needed.
57+
model.AvailableModels = [];
58+
}
59+
else
60+
{
61+
// GitHub OAuth mode — only fetch auth/models when the orchestrator is Copilot.
62+
if (string.Equals(interaction.OrchestratorName, CopilotOrchestrator.OrchestratorName, StringComparison.OrdinalIgnoreCase) &&
63+
_httpContextAccessor.HttpContext?.User is not null)
5264
{
53-
var userId = await _userManager.GetUserIdAsync(user);
54-
model.IsAuthenticated = await _oauthService.IsAuthenticatedAsync(userId);
55-
if (model.IsAuthenticated)
56-
{
57-
var credential = await _oauthService.GetCredentialAsync(userId);
58-
model.GitHubUsername = credential?.GitHubUsername;
65+
var user = await _userManager.GetUserAsync(_httpContextAccessor.HttpContext.User);
5966

60-
var models = await _oauthService.ListModelsAsync(userId);
61-
if (models.Count > 0)
67+
if (user is not null)
68+
{
69+
var userId = await _userManager.GetUserIdAsync(user);
70+
model.IsAuthenticated = await _oauthService.IsAuthenticatedAsync(userId);
71+
if (model.IsAuthenticated)
6272
{
63-
model.AvailableModels = models
64-
.Select(m => new SelectListItem(m.Name, m.Id))
65-
.ToList();
73+
var credential = await _oauthService.GetCredentialAsync(userId);
74+
model.GitHubUsername = credential?.GitHubUsername;
75+
76+
var models = await _oauthService.ListModelsAsync(userId);
77+
if (models.Count > 0)
78+
{
79+
model.AvailableModels = models
80+
.Select(m => new SelectListItem(m.Name, m.Id))
81+
.ToList();
82+
}
6683
}
6784
}
6885
}
69-
}
7086

71-
model.AvailableModels ??= [];
87+
model.AvailableModels ??= [];
88+
}
7289
}).Location("Parameters:4#Settings;1");
7390
}
7491
}

src/Modules/CrestApps.OrchardCore.AI.Chat.Copilot/Drivers/CopilotSettingsDisplayDriver.cs

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
using CrestApps.OrchardCore.AI.Chat.Copilot.Models;
12
using CrestApps.OrchardCore.AI.Chat.Copilot.Settings;
23
using CrestApps.OrchardCore.AI.Chat.Copilot.ViewModels;
34
using CrestApps.OrchardCore.AI.Core;
45
using Microsoft.AspNetCore.Authorization;
56
using Microsoft.AspNetCore.DataProtection;
67
using Microsoft.AspNetCore.Http;
78
using Microsoft.AspNetCore.Mvc.Localization;
9+
using Microsoft.AspNetCore.Mvc.Rendering;
810
using Microsoft.AspNetCore.Routing;
911
using Microsoft.Extensions.Localization;
1012
using OrchardCore.DisplayManagement.Entities;
@@ -48,12 +50,41 @@ public override IDisplayResult Edit(ISite site, CopilotSettings settings, BuildE
4850
{
4951
return Initialize<CopilotSettingsViewModel>("CopilotSettings_Edit", model =>
5052
{
53+
model.AuthenticationType = settings.AuthenticationType;
5154
model.ClientId = settings.ClientId;
5255
model.HasSecret = !string.IsNullOrWhiteSpace(settings.ProtectedClientSecret);
5356
model.ComputedCallbackUrl = _linkGenerator.GetUriByAction(_httpContextAccessor.HttpContext, "OAuthCallback", "CopilotAuth", new
5457
{
5558
area = "CrestApps.OrchardCore.AI.Chat.Copilot",
5659
});
60+
61+
// BYOK fields
62+
model.ProviderType = settings.ProviderType;
63+
model.BaseUrl = settings.BaseUrl;
64+
model.HasApiKey = !string.IsNullOrWhiteSpace(settings.ProtectedApiKey);
65+
model.WireApi = settings.WireApi ?? "completions";
66+
model.DefaultModel = settings.DefaultModel;
67+
model.AzureApiVersion = settings.AzureApiVersion;
68+
69+
// Select list options
70+
model.AuthenticationTypes =
71+
[
72+
new SelectListItem(S["GitHub Signed-in User"], nameof(CopilotAuthenticationType.GitHubOAuth)),
73+
new SelectListItem(S["API Key (BYOK)"], nameof(CopilotAuthenticationType.ApiKey)),
74+
];
75+
76+
model.ProviderTypes =
77+
[
78+
new SelectListItem(S["OpenAI / OpenAI-compatible (Ollama, vLLM, etc.)"], "openai"),
79+
new SelectListItem(S["Azure OpenAI"], "azure"),
80+
new SelectListItem(S["Anthropic"], "anthropic"),
81+
];
82+
83+
model.WireApiOptions =
84+
[
85+
new SelectListItem(S["Chat Completions (default)"], "completions"),
86+
new SelectListItem(S["Responses (GPT-5 series)"], "responses"),
87+
];
5788
})
5889
.Location("Content:8%Copilot;1")
5990
.OnGroup(SettingsGroupId)
@@ -66,24 +97,70 @@ public override async Task<IDisplayResult> UpdateAsync(ISite site, CopilotSettin
6697

6798
await context.Updater.TryUpdateModelAsync(model, Prefix);
6899

69-
settings.ClientId = model.ClientId;
100+
settings.AuthenticationType = model.AuthenticationType;
70101

71-
// Validate that client ID and secret are provided
72-
if (string.IsNullOrWhiteSpace(settings.ClientId))
102+
if (settings.AuthenticationType == CopilotAuthenticationType.GitHubOAuth)
73103
{
74-
context.Updater.ModelState.AddModelError(nameof(model.ClientId), S["Client ID is required."]);
75-
}
104+
// GitHub OAuth validation
105+
settings.ClientId = model.ClientId;
76106

77-
// Only update the secret if a new one was provided
78-
if (!string.IsNullOrWhiteSpace(model.ClientSecret))
79-
{
80-
var protector = _dataProtectionProvider.CreateProtector(ProtectorPurpose);
81-
settings.ProtectedClientSecret = protector.Protect(model.ClientSecret);
107+
if (string.IsNullOrWhiteSpace(settings.ClientId))
108+
{
109+
context.Updater.ModelState.AddModelError(nameof(model.ClientId), S["Client ID is required."]);
110+
}
111+
112+
if (!string.IsNullOrWhiteSpace(model.ClientSecret))
113+
{
114+
var protector = _dataProtectionProvider.CreateProtector(ProtectorPurpose);
115+
settings.ProtectedClientSecret = protector.Protect(model.ClientSecret);
116+
}
117+
else if (string.IsNullOrWhiteSpace(settings.ProtectedClientSecret))
118+
{
119+
context.Updater.ModelState.AddModelError(nameof(model.ClientSecret), S["Client Secret is required."]);
120+
}
82121
}
83-
else if (string.IsNullOrWhiteSpace(settings.ProtectedClientSecret))
122+
else
84123
{
85-
// No existing secret and no new secret provided
86-
context.Updater.ModelState.AddModelError(nameof(model.ClientSecret), S["Client Secret is required."]);
124+
// BYOK (API Key) validation
125+
settings.ProviderType = model.ProviderType;
126+
settings.BaseUrl = model.BaseUrl;
127+
settings.WireApi = model.WireApi;
128+
settings.DefaultModel = model.DefaultModel;
129+
settings.AzureApiVersion = model.AzureApiVersion;
130+
131+
if (string.IsNullOrWhiteSpace(settings.ProviderType))
132+
{
133+
context.Updater.ModelState.AddModelError(nameof(model.ProviderType), S["Provider Type is required."]);
134+
}
135+
136+
if (string.IsNullOrWhiteSpace(settings.BaseUrl))
137+
{
138+
context.Updater.ModelState.AddModelError(nameof(model.BaseUrl), S["Base URL is required."]);
139+
}
140+
141+
if (string.IsNullOrWhiteSpace(settings.DefaultModel))
142+
{
143+
context.Updater.ModelState.AddModelError(nameof(model.DefaultModel), S["Default Model is required."]);
144+
}
145+
146+
if (!string.IsNullOrWhiteSpace(model.ApiKey))
147+
{
148+
var protector = _dataProtectionProvider.CreateProtector(ProtectorPurpose);
149+
settings.ProtectedApiKey = protector.Protect(model.ApiKey);
150+
}
151+
152+
if (string.Equals(settings.ProviderType, "azure", StringComparison.OrdinalIgnoreCase)
153+
&& string.IsNullOrWhiteSpace(settings.AzureApiVersion))
154+
{
155+
context.Updater.ModelState.AddModelError(nameof(model.AzureApiVersion), S["Azure API Version is required for Azure provider."]);
156+
}
157+
158+
if (string.Equals(settings.ProviderType, "azure", StringComparison.OrdinalIgnoreCase)
159+
&& string.IsNullOrWhiteSpace(model.ApiKey)
160+
&& string.IsNullOrWhiteSpace(settings.ProtectedApiKey))
161+
{
162+
context.Updater.ModelState.AddModelError(nameof(model.ApiKey), S["API Key is required for Azure provider."]);
163+
}
87164
}
88165

89166
return await EditAsync(site, settings, context);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace CrestApps.OrchardCore.AI.Chat.Copilot.Models;
2+
3+
/// <summary>
4+
/// Specifies the authentication method used by the Copilot orchestrator.
5+
/// </summary>
6+
public enum CopilotAuthenticationType
7+
{
8+
/// <summary>
9+
/// Users authenticate via GitHub OAuth.
10+
/// Requires a GitHub Copilot subscription.
11+
/// </summary>
12+
GitHubOAuth,
13+
14+
/// <summary>
15+
/// Bring Your Own Key — uses a provider API key configured by the tenant owner.
16+
/// No GitHub Copilot subscription required.
17+
/// </summary>
18+
ApiKey,
19+
}

0 commit comments

Comments
 (0)