Compare commits
8 Commits
49a450a6fa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6748a3762a | |||
| cccfb45bd5 | |||
| 08fb137e18 | |||
| 59a46e8a1f | |||
| 65a3d52a5b | |||
| b90365a2ce | |||
| 2d30221119 | |||
| 52cc594f69 |
@@ -9,8 +9,8 @@ WORKDIR /src
|
||||
COPY . .
|
||||
|
||||
# Restore & publish (self-contained trimming can be added later if desired)
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c $BUILD_CONFIGURATION -o /app/publish --no-restore
|
||||
RUN dotnet restore SimpleIdp.Server/SimpleIdp.csproj
|
||||
RUN dotnet publish SimpleIdp.Server/SimpleIdp.csproj -c $BUILD_CONFIGURATION -o /app/publish --no-restore
|
||||
|
||||
# =========================
|
||||
# Runtime stage
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"profiles": {
|
||||
"SimpleIdp": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:65455;http://localhost:65456"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# simpleidp
|
||||
|
||||
Self-hosted OAuth 2.0 / OpenID Connect identity provider built with ASP.NET Core 8.0 and SimpleIdServer.
|
||||
|
||||
## Features
|
||||
|
||||
- OAuth 2.0 Authorization Code flow with PKCE support
|
||||
- OpenID Connect authentication
|
||||
- Multiple authentication methods (password, SMS, OTP, WebAuthn, mobile, email, console, verifiable presentations)
|
||||
- Multi-realm support
|
||||
- User management with roles and claims
|
||||
- Client management with configurable redirect URIs
|
||||
- Refresh tokens with configurable expiration
|
||||
- Consent management
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Framework**: .NET 8.0
|
||||
- **Identity Server**: SimpleIdServer 6.0.*-*
|
||||
- **UI**: Razor Pages
|
||||
- **Container**: Docker (multi-stage build)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Run Locally
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The server starts on:
|
||||
- HTTPS: `https://localhost:65455`
|
||||
- HTTP: `http://localhost:65456`
|
||||
|
||||
### Run in Docker
|
||||
|
||||
```bash
|
||||
docker build -t simpleidp .
|
||||
docker run -p 8080:8080 simpleidp
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is loaded from JSON files in the `config/` directory:
|
||||
|
||||
- `users.json` - User definitions (username, password, email, roles, claims, consents)
|
||||
- `clients.json` - OAuth 2.0 client definitions
|
||||
- `realm.json` - Realm configuration
|
||||
- `scopes.json` - Available scopes and claim mappers
|
||||
- `api.json` - API resource definitions
|
||||
|
||||
## Default Credentials
|
||||
|
||||
- **Username**: `administrator`
|
||||
- **Password**: `password`
|
||||
- **Email**: `adm@mail.com`
|
||||
|
||||
## Docker Hub
|
||||
|
||||
Image: `mareklesko/simpleidp`
|
||||
@@ -4,6 +4,11 @@
|
||||
<OutputType>Exe</OutputType>
|
||||
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Remove="SimpleIdp.Tests/**" />
|
||||
<EmbeddedResource Remove="SimpleIdp.Tests/**" />
|
||||
<None Remove="SimpleIdp.Tests/**" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.3.0" />
|
||||
<PackageReference Include="SimpleIdServer.IdServer.Pwd" Version="6.0.*-*" />
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"defaultProvider": "cdnjs",
|
||||
"libraries": [
|
||||
{
|
||||
"library": "jquery@3.6.3",
|
||||
"destination": "wwwroot/lib/jquery/"
|
||||
},
|
||||
{
|
||||
"library": "bootstrap@5.2.3",
|
||||
"destination": "wwwroot/lib/bootstrap/"
|
||||
},
|
||||
{
|
||||
"library": "popper.js@2.11.6",
|
||||
"destination": "wwwroot/lib/popper.js/"
|
||||
},
|
||||
{
|
||||
"library": "font-awesome@6.5.2",
|
||||
"destination": "wwwroot/lib/fontawesome/"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,380 @@
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SimpleIdp.Tests.Configuration;
|
||||
|
||||
public class ConfigurationTests
|
||||
{
|
||||
private const string TestConfigPath = "test-config.json";
|
||||
|
||||
[TearDown]
|
||||
public void Cleanup()
|
||||
{
|
||||
if (File.Exists(TestConfigPath))
|
||||
File.Delete(TestConfigPath);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deserialize_ValidConfig_ReturnsConfig()
|
||||
{
|
||||
var config = new IdpConfig(
|
||||
Api: new ApiConfig("api", "api-audience"),
|
||||
Scope: new ScopeConfig("scope", "description", new List<ClaimMapperConfig>()),
|
||||
Client: new ClientConfig("client-id", "client-name", new List<string> { "http://redirect" }),
|
||||
Users: new List<UserConfig>()
|
||||
);
|
||||
|
||||
var json = SerializeConfig(config);
|
||||
File.WriteAllText(TestConfigPath, json);
|
||||
|
||||
var result = LoadConfigFromFile(TestConfigPath);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.That(result.Api.Name, Is.EqualTo(config.Api.Name));
|
||||
Assert.That(result.Scope.Name, Is.EqualTo(config.Scope.Name));
|
||||
Assert.That(result.Client.ClientId, Is.EqualTo(config.Client.ClientId));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deserialize_WithClaimMappers_ParsesClaimMappersCorrectly()
|
||||
{
|
||||
var claimMappers = new List<ClaimMapperConfig>
|
||||
{
|
||||
new ClaimMapperConfig("claim1", "source1", "string"),
|
||||
new ClaimMapperConfig("claim2", "source2", "int", true)
|
||||
};
|
||||
|
||||
var config = new IdpConfig(
|
||||
Api: new ApiConfig("api", "audience"),
|
||||
Scope: new ScopeConfig("scope", "desc", claimMappers),
|
||||
Client: new ClientConfig("client", "name", new List<string>()),
|
||||
Users: new List<UserConfig>()
|
||||
);
|
||||
|
||||
var json = SerializeConfig(config);
|
||||
File.WriteAllText(TestConfigPath, json);
|
||||
|
||||
var result = LoadConfigFromFile(TestConfigPath);
|
||||
|
||||
Assert.IsNotNull(result.Scope.ClaimMappers);
|
||||
Assert.That(result.Scope.ClaimMappers.Count, Is.EqualTo(2));
|
||||
Assert.That(result.Scope.ClaimMappers[0].TargetClaimPath, Is.EqualTo("claim1"));
|
||||
Assert.That(result.Scope.ClaimMappers[0].SourceUserAttribute, Is.EqualTo("source1"));
|
||||
Assert.That(result.Scope.ClaimMappers[0].TokenClaimJsonType, Is.EqualTo("string"));
|
||||
Assert.That(result.Scope.ClaimMappers[1].TargetClaimPath, Is.EqualTo("claim2"));
|
||||
Assert.IsTrue(result.Scope.ClaimMappers[1].IsMultiValued);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deserialize_WithUsers_ParsesUsersCorrectly()
|
||||
{
|
||||
var users = new List<UserConfig>
|
||||
{
|
||||
new UserConfig(
|
||||
"user1",
|
||||
"pass1",
|
||||
"User One",
|
||||
"user1@test.com",
|
||||
"User",
|
||||
true,
|
||||
new List<string> { "admin" },
|
||||
new Dictionary<string, string> { { "role", "admin" } },
|
||||
new List<ConsentConfig>()
|
||||
)
|
||||
};
|
||||
|
||||
var config = new IdpConfig(
|
||||
Api: new ApiConfig("api", "audience"),
|
||||
Scope: new ScopeConfig("scope", "desc", new List<ClaimMapperConfig>()),
|
||||
Client: new ClientConfig("client", "name", new List<string>()),
|
||||
Users: users
|
||||
);
|
||||
|
||||
var json = SerializeConfig(config);
|
||||
File.WriteAllText(TestConfigPath, json);
|
||||
|
||||
var result = LoadConfigFromFile(TestConfigPath);
|
||||
|
||||
Assert.IsNotNull(result.Users);
|
||||
Assert.That(result.Users.Count, Is.EqualTo(1));
|
||||
Assert.That(result.Users[0].Login, Is.EqualTo("user1"));
|
||||
Assert.That(result.Users[0].Password, Is.EqualTo("pass1"));
|
||||
Assert.That(result.Users[0].Name, Is.EqualTo("User One"));
|
||||
Assert.That(result.Users[0].Email, Is.EqualTo("user1@test.com"));
|
||||
Assert.IsTrue(result.Users[0].EmailVerified);
|
||||
Assert.That(result.Users[0].Roles.Count, Is.EqualTo(1));
|
||||
Assert.That(result.Users[0].Roles[0], Is.EqualTo("admin"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deserialize_WithConsents_ParsesConsentsCorrectly()
|
||||
{
|
||||
var consents = new List<ConsentConfig>
|
||||
{
|
||||
new ConsentConfig("realm1", "client1", "scope1")
|
||||
};
|
||||
|
||||
var users = new List<UserConfig>
|
||||
{
|
||||
new UserConfig(
|
||||
"user1",
|
||||
"pass1",
|
||||
"User One",
|
||||
"user1@test.com",
|
||||
"User",
|
||||
true,
|
||||
new List<string>(),
|
||||
new Dictionary<string, string>(),
|
||||
consents
|
||||
)
|
||||
};
|
||||
|
||||
var config = new IdpConfig(
|
||||
Api: new ApiConfig("api", "audience"),
|
||||
Scope: new ScopeConfig("scope", "desc", new List<ClaimMapperConfig>()),
|
||||
Client: new ClientConfig("client", "name", new List<string>()),
|
||||
Users: users
|
||||
);
|
||||
|
||||
var json = SerializeConfig(config);
|
||||
File.WriteAllText(TestConfigPath, json);
|
||||
|
||||
var result = LoadConfigFromFile(TestConfigPath);
|
||||
|
||||
Assert.IsNotNull(result.Users[0].Consents);
|
||||
Assert.That(result.Users[0].Consents.Count, Is.EqualTo(1));
|
||||
Assert.That(result.Users[0].Consents[0].Realm, Is.EqualTo("realm1"));
|
||||
Assert.That(result.Users[0].Consents[0].ClientId, Is.EqualTo("client1"));
|
||||
Assert.That(result.Users[0].Consents[0].Scope, Is.EqualTo("scope1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deserialize_CaseInsensitive_ParsesCorrectly()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"api": {
|
||||
"name": "test-api",
|
||||
"audience": "test-audience"
|
||||
},
|
||||
"scope": {
|
||||
"name": "test-scope",
|
||||
"description": "test-desc",
|
||||
"claimmappers": []
|
||||
},
|
||||
"client": {
|
||||
"clientid": "test-client",
|
||||
"name": "test client",
|
||||
"redirecturis": ["http://test.com"],
|
||||
"ispublic": false
|
||||
},
|
||||
"users": []
|
||||
}
|
||||
""";
|
||||
|
||||
File.WriteAllText(TestConfigPath, json);
|
||||
|
||||
var result = LoadConfigFromFile(TestConfigPath);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.That(result.Api.Name, Is.EqualTo("test-api"));
|
||||
Assert.That(result.Client.ClientId, Is.EqualTo("test-client"));
|
||||
Assert.IsFalse(result.Client.IsPublic);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deserialize_MissingApiConfig_ReturnsNullApi()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"scope": { "name": "scope", "description": "desc", "claimmappers": [] },
|
||||
"client": { "clientid": "client", "name": "name", "redirecturis": [] },
|
||||
"users": []
|
||||
}
|
||||
""";
|
||||
|
||||
File.WriteAllText(TestConfigPath, json);
|
||||
|
||||
var result = LoadConfigFromFile(TestConfigPath);
|
||||
|
||||
Assert.IsNull(result.Api);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deserialize_MissingScopeConfig_ReturnsNullScope()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"api": { "name": "api", "audience": "audience" },
|
||||
"client": { "clientid": "client", "name": "name", "redirecturis": [] },
|
||||
"users": []
|
||||
}
|
||||
""";
|
||||
|
||||
File.WriteAllText(TestConfigPath, json);
|
||||
|
||||
var result = LoadConfigFromFile(TestConfigPath);
|
||||
|
||||
Assert.IsNull(result.Scope);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deserialize_MissingClientConfig_ReturnsNullClient()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"api": { "name": "api", "audience": "audience" },
|
||||
"scope": { "name": "scope", "description": "desc", "claimmappers": [] },
|
||||
"users": []
|
||||
}
|
||||
""";
|
||||
|
||||
File.WriteAllText(TestConfigPath, json);
|
||||
|
||||
var result = LoadConfigFromFile(TestConfigPath);
|
||||
|
||||
Assert.IsNull(result.Client);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deserialize_EmptyUsersList_ParsesSuccessfully()
|
||||
{
|
||||
var config = new IdpConfig(
|
||||
Api: new ApiConfig("api", "audience"),
|
||||
Scope: new ScopeConfig("scope", "desc", new List<ClaimMapperConfig>()),
|
||||
Client: new ClientConfig("client", "name", new List<string>()),
|
||||
Users: new List<UserConfig>()
|
||||
);
|
||||
|
||||
var json = SerializeConfig(config);
|
||||
File.WriteAllText(TestConfigPath, json);
|
||||
|
||||
var result = LoadConfigFromFile(TestConfigPath);
|
||||
|
||||
Assert.IsNotNull(result.Users);
|
||||
Assert.That(result.Users, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deserialize_MultipleUsers_ParsesAllUsers()
|
||||
{
|
||||
var users = new List<UserConfig>
|
||||
{
|
||||
new UserConfig("user1", "pass1", "User 1", "user1@test.com", "User", true, new List<string>(), new Dictionary<string, string>(), new List<ConsentConfig>()),
|
||||
new UserConfig("user2", "pass2", "User 2", "user2@test.com", "User", false, new List<string>(), new Dictionary<string, string>(), new List<ConsentConfig>()),
|
||||
new UserConfig("user3", "pass3", "User 3", "user3@test.com", "User", true, new List<string>(), new Dictionary<string, string>(), new List<ConsentConfig>())
|
||||
};
|
||||
|
||||
var config = new IdpConfig(
|
||||
Api: new ApiConfig("api", "audience"),
|
||||
Scope: new ScopeConfig("scope", "desc", new List<ClaimMapperConfig>()),
|
||||
Client: new ClientConfig("client", "name", new List<string>()),
|
||||
Users: users
|
||||
);
|
||||
|
||||
var json = SerializeConfig(config);
|
||||
File.WriteAllText(TestConfigPath, json);
|
||||
|
||||
var result = LoadConfigFromFile(TestConfigPath);
|
||||
|
||||
Assert.That(result.Users.Count, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deserialize_ClientWithMultipleRedirectUris_ParsesAllUris()
|
||||
{
|
||||
var redirectUris = new List<string>
|
||||
{
|
||||
"http://redirect1.com",
|
||||
"http://redirect2.com",
|
||||
"https://redirect3.com"
|
||||
};
|
||||
|
||||
var config = new IdpConfig(
|
||||
Api: new ApiConfig("api", "audience"),
|
||||
Scope: new ScopeConfig("scope", "desc", new List<ClaimMapperConfig>()),
|
||||
Client: new ClientConfig("client", "name", redirectUris),
|
||||
Users: new List<UserConfig>()
|
||||
);
|
||||
|
||||
var json = SerializeConfig(config);
|
||||
File.WriteAllText(TestConfigPath, json);
|
||||
|
||||
var result = LoadConfigFromFile(TestConfigPath);
|
||||
|
||||
Assert.That(result.Client.RedirectUris.Count, Is.EqualTo(3));
|
||||
Assert.That(result.Client.RedirectUris, Contains.Item("http://redirect1.com"));
|
||||
Assert.That(result.Client.RedirectUris, Contains.Item("http://redirect2.com"));
|
||||
Assert.That(result.Client.RedirectUris, Contains.Item("https://redirect3.com"));
|
||||
}
|
||||
|
||||
private string SerializeConfig(IdpConfig config)
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(config, options);
|
||||
}
|
||||
|
||||
private IdpConfig LoadConfigFromFile(string path)
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
return JsonSerializer.Deserialize<IdpConfig>(json, options)
|
||||
?? throw new InvalidOperationException("Failed to deserialize config");
|
||||
}
|
||||
}
|
||||
|
||||
public record IdpConfig(
|
||||
ApiConfig Api,
|
||||
ScopeConfig Scope,
|
||||
ClientConfig Client,
|
||||
List<UserConfig> Users);
|
||||
|
||||
public record ApiConfig(
|
||||
string Name,
|
||||
string Audience);
|
||||
|
||||
public record ScopeConfig(
|
||||
string Name,
|
||||
string Description,
|
||||
List<ClaimMapperConfig> ClaimMappers);
|
||||
|
||||
public record ClaimMapperConfig(
|
||||
string TargetClaimPath,
|
||||
string SourceUserAttribute,
|
||||
string TokenClaimJsonType,
|
||||
bool IsMultiValued = false);
|
||||
|
||||
public record ClientConfig(
|
||||
string ClientId,
|
||||
string Name,
|
||||
List<string> RedirectUris,
|
||||
bool IsPublic = true);
|
||||
|
||||
public record UserConfig(
|
||||
string Login,
|
||||
string Password,
|
||||
string Name,
|
||||
string Email,
|
||||
string Firstname,
|
||||
bool EmailVerified,
|
||||
List<string> Roles,
|
||||
Dictionary<string, string> Claims,
|
||||
List<ConsentConfig> Consents);
|
||||
|
||||
public record ConsentConfig(
|
||||
string Realm,
|
||||
string ClientId,
|
||||
string Scope);
|
||||