Skip to content

Commit dbd35d1

Browse files
authored
Merge pull request #352 from keboola/upstream/api-key-auth
Add API key authentication and MCP search improvements
2 parents 8d981d3 + 742ce9b commit dbd35d1

32 files changed

Lines changed: 1579 additions & 14 deletions

src/OpenDeepWiki.EFCore/MasterDbContext.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public interface IContext : IDisposable
4646
DbSet<McpProvider> McpProviders { get; set; }
4747
DbSet<McpUsageLog> McpUsageLogs { get; set; }
4848
DbSet<McpDailyStatistics> McpDailyStatistics { get; set; }
49+
DbSet<ApiKey> ApiKeys { get; set; }
4950

5051
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
5152
}
@@ -97,6 +98,7 @@ protected MasterDbContext(DbContextOptions options)
9798
public DbSet<McpProvider> McpProviders { get; set; } = null!;
9899
public DbSet<McpUsageLog> McpUsageLogs { get; set; } = null!;
99100
public DbSet<McpDailyStatistics> McpDailyStatistics { get; set; } = null!;
101+
public DbSet<ApiKey> ApiKeys { get; set; } = null!;
100102

101103
protected override void OnModelCreating(ModelBuilder modelBuilder)
102104
{
@@ -394,5 +396,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
394396
.WithMany()
395397
.HasForeignKey(g => g.DepartmentId)
396398
.OnDelete(DeleteBehavior.SetNull);
399+
400+
// ApiKey indexes
401+
modelBuilder.Entity<ApiKey>(entity =>
402+
{
403+
entity.HasIndex(e => e.KeyPrefix).IsUnique();
404+
entity.HasIndex(e => e.UserId);
405+
});
397406
}
398407
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using System.ComponentModel.DataAnnotations.Schema;
3+
4+
namespace OpenDeepWiki.Entities;
5+
6+
/// <summary>
7+
/// API Key entity for headless/M2M authentication
8+
/// </summary>
9+
public class ApiKey : AggregateRoot<string>
10+
{
11+
/// <summary>
12+
/// Human-readable label for the API key
13+
/// </summary>
14+
[Required]
15+
[StringLength(50)]
16+
public string Name { get; set; } = string.Empty;
17+
18+
/// <summary>
19+
/// First 8 chars of random part for lookup
20+
/// </summary>
21+
[Required]
22+
[StringLength(12)]
23+
public string KeyPrefix { get; set; } = string.Empty;
24+
25+
/// <summary>
26+
/// SHA-256 hex of full token
27+
/// </summary>
28+
[Required]
29+
[StringLength(64)]
30+
public string KeyHash { get; set; } = string.Empty;
31+
32+
/// <summary>
33+
/// Foreign key to User
34+
/// </summary>
35+
[Required]
36+
[StringLength(36)]
37+
public string UserId { get; set; } = string.Empty;
38+
39+
/// <summary>
40+
/// Permission scope
41+
/// </summary>
42+
[StringLength(50)]
43+
public string Scope { get; set; } = "mcp:read";
44+
45+
/// <summary>
46+
/// Expiration date (null = never expires)
47+
/// </summary>
48+
public DateTime? ExpiresAt { get; set; }
49+
50+
/// <summary>
51+
/// Last time this key was used
52+
/// </summary>
53+
public DateTime? LastUsedAt { get; set; }
54+
55+
/// <summary>
56+
/// IP address of last usage
57+
/// </summary>
58+
[StringLength(50)]
59+
public string? LastUsedIp { get; set; }
60+
61+
/// <summary>
62+
/// User navigation property
63+
/// </summary>
64+
[ForeignKey("UserId")]
65+
public virtual User? User { get; set; }
66+
}

src/OpenDeepWiki.Entities/Repositories/Repository.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,12 @@ public class Repository : AggregateRoot<string>
123123
/// </summary>
124124
public DateTime? LastUpdateCheckAt { get; set; }
125125

126+
/// <summary>
127+
/// Repository description (from GitHub)
128+
/// </summary>
129+
[StringLength(1000)]
130+
public string? Description { get; set; }
131+
126132
/// <summary>
127133
/// Whether this repository is owned by a department (org import) rather than an individual user.
128134
/// When true, the repo appears in Organization view only, not in the importing user's "My Repos".
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using OpenDeepWiki.Services.Admin;
2+
3+
namespace OpenDeepWiki.Endpoints.Admin;
4+
5+
public static class AdminApiKeyEndpoints
6+
{
7+
public static RouteGroupBuilder MapAdminApiKeyEndpoints(this RouteGroupBuilder group)
8+
{
9+
var apiKeys = group.MapGroup("/api-keys");
10+
11+
apiKeys.MapPost("/", async (CreateApiKeyRequest request, IAdminApiKeyService service) =>
12+
{
13+
try
14+
{
15+
var result = await service.CreateApiKeyAsync(request.Name, request.UserId, request.Scope, request.ExpiresInDays);
16+
return Results.Ok(result);
17+
}
18+
catch (ArgumentException ex)
19+
{
20+
return Results.BadRequest(new { error = true, message = ex.Message });
21+
}
22+
});
23+
24+
apiKeys.MapGet("/", async (IAdminApiKeyService service) =>
25+
{
26+
var keys = await service.ListApiKeysAsync();
27+
return Results.Ok(keys);
28+
});
29+
30+
apiKeys.MapDelete("/{id}", async (string id, IAdminApiKeyService service) =>
31+
{
32+
var revoked = await service.RevokeApiKeyAsync(id);
33+
return revoked ? Results.Ok(new { message = "API key revoked" }) : Results.NotFound(new { error = true, message = "API key not found" });
34+
});
35+
36+
return apiKeys;
37+
}
38+
}
39+
40+
public class CreateApiKeyRequest
41+
{
42+
public required string Name { get; set; }
43+
public required string UserId { get; set; }
44+
public string? Scope { get; set; }
45+
public int? ExpiresInDays { get; set; }
46+
}

src/OpenDeepWiki/Endpoints/Admin/AdminEndpoints.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder
2121
adminGroup.MapAdminSettingsEndpoints();
2222
adminGroup.MapAdminChatAssistantEndpoints();
2323
adminGroup.MapAdminMcpProviderEndpoints();
24+
adminGroup.MapAdminApiKeyEndpoints();
2425

2526
return app;
2627
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using OpenDeepWiki.Services.Admin;
2+
using OpenDeepWiki.Services.Auth;
3+
4+
namespace OpenDeepWiki.Endpoints;
5+
6+
public static class ApiKeyEndpoints
7+
{
8+
public static IEndpointRouteBuilder MapApiKeyEndpoints(this IEndpointRouteBuilder app)
9+
{
10+
var group = app.MapGroup("/api/auth/api-keys")
11+
.WithTags("API Keys")
12+
.RequireAuthorization();
13+
14+
// List own API keys
15+
group.MapGet("/", async (IUserContext userContext, IAdminApiKeyService service) =>
16+
{
17+
if (string.IsNullOrEmpty(userContext.UserId))
18+
return Results.Unauthorized();
19+
20+
var keys = await service.ListApiKeysForUserAsync(userContext.UserId);
21+
return Results.Ok(keys);
22+
});
23+
24+
// Create own API key
25+
group.MapPost("/", async (CreateUserApiKeyRequest request, IUserContext userContext, IAdminApiKeyService service) =>
26+
{
27+
if (string.IsNullOrEmpty(userContext.UserId))
28+
return Results.Unauthorized();
29+
30+
try
31+
{
32+
var result = await service.CreateApiKeyForUserAsync(
33+
userContext.UserId, request.Name, request.Scope, request.ExpiresInDays);
34+
return Results.Ok(result);
35+
}
36+
catch (ArgumentException ex)
37+
{
38+
return Results.BadRequest(new { error = true, message = ex.Message });
39+
}
40+
});
41+
42+
// Revoke own API key
43+
group.MapDelete("/{id}", async (string id, IUserContext userContext, IAdminApiKeyService service) =>
44+
{
45+
if (string.IsNullOrEmpty(userContext.UserId))
46+
return Results.Unauthorized();
47+
48+
var revoked = await service.RevokeApiKeyForUserAsync(userContext.UserId, id);
49+
return revoked
50+
? Results.Ok(new { message = "API key revoked" })
51+
: Results.NotFound(new { error = true, message = "API key not found" });
52+
});
53+
54+
return app;
55+
}
56+
}
57+
58+
public class CreateUserApiKeyRequest
59+
{
60+
public required string Name { get; set; }
61+
public string? Scope { get; set; }
62+
public int? ExpiresInDays { get; set; }
63+
}

src/OpenDeepWiki/Infrastructure/DbInitializer.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,45 @@ public static async Task InitializeAsync(IServiceProvider serviceProvider)
3434
// 初始化OAuth提供商
3535
await InitializeOAuthProvidersAsync(context);
3636

37+
// Schema migrations for existing databases
38+
if (context is DbContext migrationCtx)
39+
{
40+
// Create ApiKeys table if not exists
41+
await migrationCtx.Database.ExecuteSqlRawAsync(@"
42+
CREATE TABLE IF NOT EXISTS ApiKeys (
43+
Id TEXT NOT NULL PRIMARY KEY,
44+
Name TEXT NOT NULL,
45+
KeyPrefix TEXT NOT NULL,
46+
KeyHash TEXT NOT NULL,
47+
UserId TEXT NOT NULL,
48+
Scope TEXT NOT NULL DEFAULT 'mcp:read',
49+
ExpiresAt TEXT,
50+
LastUsedAt TEXT,
51+
LastUsedIp TEXT,
52+
CreatedAt TEXT NOT NULL,
53+
UpdatedAt TEXT,
54+
DeletedAt TEXT,
55+
IsDeleted INTEGER NOT NULL DEFAULT 0,
56+
Version BLOB,
57+
FOREIGN KEY (UserId) REFERENCES Users(Id)
58+
)");
59+
await migrationCtx.Database.ExecuteSqlRawAsync(@"
60+
CREATE UNIQUE INDEX IF NOT EXISTS IX_ApiKeys_KeyPrefix ON ApiKeys (KeyPrefix)");
61+
await migrationCtx.Database.ExecuteSqlRawAsync(@"
62+
CREATE INDEX IF NOT EXISTS IX_ApiKeys_UserId ON ApiKeys (UserId)");
63+
64+
// Add Description column to Repositories if not exists
65+
try
66+
{
67+
await migrationCtx.Database.ExecuteSqlRawAsync(
68+
"ALTER TABLE Repositories ADD COLUMN Description TEXT");
69+
}
70+
catch
71+
{
72+
// Column already exists -- ignore
73+
}
74+
}
75+
3776
// 初始化系统设置默认值(仅在首次运行时从环境变量创建)
3877
await SystemSettingDefaults.InitializeDefaultsAsync(configuration, context);
3978
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
using System.Security.Claims;
2+
using System.Security.Cryptography;
3+
using System.Text;
4+
using System.Text.Encodings.Web;
5+
using Microsoft.AspNetCore.Authentication;
6+
using Microsoft.EntityFrameworkCore;
7+
using Microsoft.Extensions.Options;
8+
using OpenDeepWiki.EFCore;
9+
10+
namespace OpenDeepWiki.MCP;
11+
12+
public class ApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
13+
{
14+
private const string ApiKeyPrefix = "dwk_";
15+
private readonly IServiceScopeFactory _scopeFactory;
16+
17+
public ApiKeyAuthenticationHandler(
18+
IOptionsMonitor<AuthenticationSchemeOptions> options,
19+
ILoggerFactory logger,
20+
UrlEncoder encoder,
21+
IServiceScopeFactory scopeFactory)
22+
: base(options, logger, encoder)
23+
{
24+
_scopeFactory = scopeFactory;
25+
}
26+
27+
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
28+
{
29+
var authHeader = Request.Headers.Authorization.ToString();
30+
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
31+
return AuthenticateResult.NoResult();
32+
33+
var token = authHeader["Bearer ".Length..].Trim();
34+
if (!token.StartsWith(ApiKeyPrefix))
35+
return AuthenticateResult.NoResult();
36+
37+
// Extract prefix and compute hash
38+
var randomPart = token[ApiKeyPrefix.Length..];
39+
if (randomPart.Length < 8)
40+
return AuthenticateResult.Fail("Invalid API key format");
41+
42+
var keyPrefix = randomPart[..8];
43+
var tokenBytes = Encoding.UTF8.GetBytes(token);
44+
var hashBytes = SHA256.HashData(tokenBytes);
45+
var keyHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
46+
47+
// Look up in database using a new scope (handler is singleton-like)
48+
using var scope = _scopeFactory.CreateScope();
49+
var context = scope.ServiceProvider.GetRequiredService<IContext>();
50+
51+
var apiKey = await context.ApiKeys
52+
.Include(k => k.User)
53+
.FirstOrDefaultAsync(k => k.KeyPrefix == keyPrefix && !k.IsDeleted);
54+
55+
if (apiKey == null)
56+
return AuthenticateResult.Fail("Invalid API key");
57+
58+
// Constant-time hash comparison
59+
var storedHashBytes = Convert.FromHexString(apiKey.KeyHash);
60+
if (!CryptographicOperations.FixedTimeEquals(hashBytes, storedHashBytes))
61+
return AuthenticateResult.Fail("Invalid API key");
62+
63+
// Check expiration
64+
if (apiKey.ExpiresAt.HasValue && apiKey.ExpiresAt.Value < DateTime.UtcNow)
65+
return AuthenticateResult.Fail("API key has expired");
66+
67+
// Check user exists and is not deleted
68+
if (apiKey.User == null || apiKey.User.IsDeleted)
69+
return AuthenticateResult.Fail("Associated user not found or disabled");
70+
71+
// Load user roles
72+
var userRoles = await context.UserRoles
73+
.Where(ur => ur.UserId == apiKey.UserId)
74+
.Join(context.Roles.Where(r => !r.IsDeleted),
75+
ur => ur.RoleId, r => r.Id,
76+
(ur, r) => r.Name)
77+
.ToListAsync();
78+
79+
// Build claims (same as JwtService)
80+
var claims = new List<Claim>
81+
{
82+
new(ClaimTypes.NameIdentifier, apiKey.UserId),
83+
new(ClaimTypes.Name, apiKey.User.Name ?? string.Empty),
84+
new(ClaimTypes.Email, apiKey.User.Email ?? string.Empty),
85+
};
86+
foreach (var role in userRoles)
87+
{
88+
claims.Add(new Claim(ClaimTypes.Role, role));
89+
}
90+
91+
var identity = new ClaimsIdentity(claims, Scheme.Name);
92+
var principal = new ClaimsPrincipal(identity);
93+
var ticket = new AuthenticationTicket(principal, Scheme.Name);
94+
95+
// Capture values before fire-and-forget (HttpContext may be recycled after response completes)
96+
var apiKeyId = apiKey.Id;
97+
var remoteIp = Request.HttpContext.Connection.RemoteIpAddress?.ToString();
98+
99+
// Update last used info (fire and forget)
100+
_ = Task.Run(async () =>
101+
{
102+
try
103+
{
104+
using var updateScope = _scopeFactory.CreateScope();
105+
var updateContext = updateScope.ServiceProvider.GetRequiredService<IContext>();
106+
var keyToUpdate = await updateContext.ApiKeys.FindAsync(apiKeyId);
107+
if (keyToUpdate != null)
108+
{
109+
keyToUpdate.LastUsedAt = DateTime.UtcNow;
110+
keyToUpdate.LastUsedIp = remoteIp;
111+
await updateContext.SaveChangesAsync();
112+
}
113+
}
114+
catch (Exception ex)
115+
{
116+
Logger.LogWarning(ex, "Failed to update API key last used info for prefix {KeyPrefix}", keyPrefix);
117+
}
118+
});
119+
120+
Logger.LogInformation("API key authenticated: prefix={KeyPrefix}, user={Email}", keyPrefix, apiKey.User.Email);
121+
return AuthenticateResult.Success(ticket);
122+
}
123+
}

0 commit comments

Comments
 (0)