Skip to content
Merged
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
41 changes: 40 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ case-insensitively:

| SubmodelElement `idShort` | Type | Result |
|---------------------------|-------------------------------|-------------------------------------------------------------------------------------------------------------|
| `authenticationServer` | `SubmodelElementCollection` | `endpoint` / `type` / `publicCertificate` → `Program.redirectServer`, `Program.authType`, cert in `GlobalSecurityVariables.ServerCertificates`. Also reads side-car files `trustlist.txt` / `trustlist.xml` for additional issuer certs, JWKS URLs and kids. |
| `authenticationServer` | `SubmodelElementCollection` | `endpoint` / `type` / `publicCertificate` → `Program.redirectServer`, `Program.authType`, cert in `GlobalSecurityVariables.TrustedServers`. Also reads side-car files `trustlist.txt` / `trustlist.xml` for trusted token issuers (see §3.3). |
| `roleMapping` | `SubmodelElementCollection` | Per entry: `subjects` (`email`, `emailDomain`, or any property `idShort`) × `roles` → `GlobalSecurityVariables.SecurityRights` entries (`SecurityRight { Name=subject, Role=role }`). |
| `basicAuth` | `SubmodelElementCollection` | Each property is added to `GlobalSecurityVariables.SecurityUsernamePassword` (`IdShort` → `Value`). |

Expand Down Expand Up @@ -112,6 +112,45 @@ properties with their password as `Value`:
> collection (or disable it) and provide an `authenticationServer` entry that
> points at a real OIDC / JWKS provider.

### 3.3 Trusted token issuers (`trustlist.txt` / `trustlist.xml`)

Bearer tokens (other than Entra ID tokens) are only accepted from issuers listed
in a trust list. A token with a `kid` header is matched against the trust list
first by its `iss` claim, then by its `kid`; tokens from unlisted issuers are
rejected. The JWKS is always taken from the trust list entry, never from the
token. Tokens with a `serverName` claim are validated against the certificate
entries.

`trustlist.txt` is a list of `key: value` lines, lines starting with `#` are
comments. `serverName`, `domain`, `jwks` and `audience` are collected for the
next entry; an entry is completed by:

| Line | Entry |
|-----------------------------|---------------------------------------------------------------------------------------------------------|
| `issuer: <url>` | OIDC issuer. `iss` must equal the url (trailing `/` ignored). Keys from `jwks`, else from `<url>/.well-known/openid-configuration`. |
| `kid: <key id>` | Single key id, requires `jwks`. The issuer is not validated. |
| `-----END CERTIFICATE-----` | PEM certificate for tokens with `serverName: <serverName>`. |

`audience: a, b` lists accepted audiences (any of them must be in `aud`). Without
an `audience` line the comma-separated environment variable `TOKEN_AUDIENCE` is
used; if neither is set, the audience is not validated (a warning is logged).
`domain` is attached as `domain` claim to tokens that carry a user name.
All values are reset after each completed entry.

```
# Keycloak realm, service client with Audience mapper "aasx-server"
audience: aasx-server
issuer: https://identity.example.com/realms/fx
```

`trustlist.xml` (ETSI trust service list, optionally from the url in `TRUST_LIST`
and signature-checked with `publickey.pem`) adds one issuer entry per
`ServiceSupplyPoint`.

Tokens without a user name (e.g. client-credentials service accounts) get no
role from `roleMapping`; grant them access with rules on their token claims,
e.g. `$contains` on `token:realm_access` for a Keycloak realm role.

---

## 4. `AllAccessPermissionRules` — rule language at `/Access`
Expand Down
8 changes: 0 additions & 8 deletions src/AasSecurity/AasSecurityAuthenticationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,6 @@ protected override Task<AuthenticateResult> HandleAuthenticateAsync()

_logger.LogInformation($"Request is successfully authenticated.");

_logger.LogInformation("***************** LIST TICKET CLAIMS ********************");

foreach (var tickeClaim in ticket.Principal.Claims)
{
_logger.LogInformation($"CLAIM {tickeClaim?.ToString()}");
_logger.LogInformation($"next claim");
}

return Task.FromResult(AuthenticateResult.Success(ticket));

}
Expand Down
10 changes: 2 additions & 8 deletions src/AasSecurity/GlobalSecurityVariables.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,16 @@
********************************************************************************/

using AasSecurity.Models;
using System.Security.Cryptography.X509Certificates;

namespace AasSecurity
{
public static class GlobalSecurityVariables
{
public static bool WithAuthentication { get; set; }
public static List<SecurityRole> SecurityRoles = new();
internal static List<X509Certificate2> ServerCertificates = new();
internal static List<string> ServerCertFileNames = new();
internal static List<string> ServerDomain = new();
internal static List<string> ServerJwksUrl = new();
internal static List<string> ServerIssuerUrl = new(); //Used when parsing from trustlist.xml
internal static List<string> ServerKid = new();
internal static readonly List<TrustedServer> TrustedServers = new();
internal static List<SecurityRight> SecurityRights = new();
internal static Dictionary<string, string> SecurityUsernamePassword = new();
internal static readonly Dictionary<string, string> SecurityUsernamePassword = new();
internal static Property ConditionSM = null;
internal static Property ConditionSME = null;
}
Expand Down
143 changes: 143 additions & 0 deletions src/AasSecurity/JwksTokenValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/********************************************************************************
* Copyright (c) {2019 - 2024} Contributors to the Eclipse Foundation
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Apache License Version 2.0 which is available at
* https://www.apache.org/licenses/LICENSE-2.0
*
* SPDX-License-Identifier: Apache-2.0
********************************************************************************/

using System.Collections.Concurrent;
using System.IdentityModel.Tokens.Jwt;
using System.Text.Json;
using AasSecurity.Models;
using AasxServerStandardBib.Services;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;

namespace AasSecurity
{
/// <summary>
/// Validates bearer tokens with a "kid" header against the JWKS of a trusted issuer.
/// Only issuers/kids from the trust list are accepted; JWKS urls are derived from the trust list
/// entry only, never from the token itself.
/// </summary>
internal static class JwksTokenValidator
{
private static readonly ConcurrentDictionary<TrustedServer, bool> _warnedNoAudience = new();

/// <param name="httpGet">Fetches the content of a url (injected for testability).</param>
/// <param name="globalAudienceCsv">Fallback audiences (TOKEN_AUDIENCE) for entries without own audience.</param>
internal static bool TryValidate(string bearerToken, string? kid, string? iss, IReadOnlyList<TrustedServer> entries,
Func<string, string> httpGet, string? globalAudienceCsv, ILogger logger, out string domain)
{
domain = "";

var entry = SecurityHelper.FindTrustedJwksEntry(entries, kid, iss);
if (entry == null)
{
logger.LogWarning("Token rejected: issuer {Issuer} / kid {Kid} is not in the trust list.",
LogSanitizer.Sanitize(iss), LogSanitizer.Sanitize(kid));
return false;
}

var jwksUrl = ResolveJwksUrl(entry, httpGet, logger);
if (string.IsNullOrEmpty(jwksUrl))
{
logger.LogWarning("Token rejected: no jwks url for issuer {Issuer} / kid {Kid}.",
LogSanitizer.Sanitize(iss), LogSanitizer.Sanitize(kid));
return false;
}

try
{
var signingKeys = new JsonWebKeySet(httpGet(jwksUrl)).GetSigningKeys();
var audiences = ResolveAudiences(entry, globalAudienceCsv);
if (audiences.Count == 0 && _warnedNoAudience.TryAdd(entry, true))
{
logger.LogWarning("No audience configured for issuer {Issuer} / kid {Kid} and TOKEN_AUDIENCE is not set: audience is not validated.",
LogSanitizer.Sanitize(iss), LogSanitizer.Sanitize(kid));
}

var validationParameters = BuildParameters(entry, signingKeys, audiences);
new JwtSecurityTokenHandler().ValidateToken(bearerToken, validationParameters, out _);
}
catch (Exception ex)
{
logger.LogWarning("Token rejected for issuer {Issuer} / kid {Kid}: {Reason}",
LogSanitizer.Sanitize(iss), LogSanitizer.Sanitize(kid), LogSanitizer.Sanitize(ex.Message));
return false;
}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment on lines +69 to +74

domain = entry.Domain;
return true;
}

internal static string? ResolveJwksUrl(TrustedServer entry, Func<string, string> httpGet, ILogger logger)
{
if (!string.IsNullOrEmpty(entry.JwksUrl))
{
return entry.JwksUrl;
}

var issuer = SecurityHelper.NormalizeIssuer(entry.Issuer);
if (issuer == "")
{
return null;
}

try
{
using var openIdConfig = JsonDocument.Parse(httpGet($"{issuer}/.well-known/openid-configuration"));
if (openIdConfig.RootElement.TryGetProperty("jwks_uri", out var jwksUri))
{
return jwksUri.GetString();
}
}
catch (Exception ex)
{
logger.LogWarning("Could not access well-known from {Issuer}: {Reason}", LogSanitizer.Sanitize(issuer), LogSanitizer.Sanitize(ex.Message));
}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment on lines +101 to +104

return $"{issuer}/jwks";
}

internal static IReadOnlyList<string> ResolveAudiences(TrustedServer entry, string? globalAudienceCsv)
{
if (entry.Audiences.Count > 0)
{
return entry.Audiences;
}

return (globalAudienceCsv ?? "").Split(',').Select(a => a.Trim()).Where(a => a != "").ToList();
}

internal static TokenValidationParameters BuildParameters(TrustedServer entry, IEnumerable<SecurityKey> signingKeys,
IReadOnlyList<string> audiences)
{
var parameters = new TokenValidationParameters
{
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
RequireSignedTokens = true,
IssuerSigningKeys = signingKeys,
// Entries matched by kid only have no configured issuer; their keys are pinned by the configured jwks url.
ValidateIssuer = entry.Issuer != "",
ValidateAudience = audiences.Count > 0,
ValidAudiences = audiences
};

if (entry.Issuer != "")
{
var normalized = SecurityHelper.NormalizeIssuer(entry.Issuer);
parameters.ValidIssuers = new[] { entry.Issuer, normalized, normalized + "/" }.Distinct().ToList();
}

return parameters;
}
}
}
42 changes: 42 additions & 0 deletions src/AasSecurity/Models/TrustedServer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/********************************************************************************
* Copyright (c) {2019 - 2024} Contributors to the Eclipse Foundation
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Apache License Version 2.0 which is available at
* https://www.apache.org/licenses/LICENSE-2.0
*
* SPDX-License-Identifier: Apache-2.0
********************************************************************************/

using System.Security.Cryptography.X509Certificates;

namespace AasSecurity.Models
{
/// <summary>
/// One trusted token issuer, read from trustlist.txt, trustlist.xml or the publicCertificate
/// of the SecuritySettingsForServer submodel.
/// A certificate entry (Certificate + CertFileName) validates tokens carrying a "serverName" claim.
/// A JWKS entry (Issuer and/or Kid, optional JwksUrl) validates tokens carrying a "kid" header.
/// </summary>
internal sealed class TrustedServer
{
public X509Certificate2? Certificate { get; init; }
public string CertFileName { get; init; } = "";
public string Domain { get; init; } = "";
public string JwksUrl { get; init; } = "";
public string Issuer { get; init; } = "";
public string Kid { get; init; } = "";
public IReadOnlyList<string> Audiences { get; init; } = [];
public string Source { get; init; } = "";

public bool IsJwksEntry => !string.IsNullOrEmpty(Issuer) || !string.IsNullOrEmpty(Kid);

public override string ToString() =>
!string.IsNullOrEmpty(Issuer) ? $"issuer {Issuer} ({Source})" :
!string.IsNullOrEmpty(Kid) ? $"kid {Kid} ({Source})" :
$"certificate {CertFileName} ({Source})";
}
}
61 changes: 34 additions & 27 deletions src/AasSecurity/SecurityHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* SPDX-License-Identifier: Apache-2.0
********************************************************************************/

using AasSecurity.Models;
using AasxServer;
using Extensions;
using Microsoft.IdentityModel.Tokens;
Expand All @@ -33,11 +34,11 @@ public static void SecurityInit()
}

private static void ParseSecurityMetamodel()
{
if (Program.env == null)
{
return;
}
{
if (Program.env == null)
{
return;
}

foreach (var env in Program.env)
{
Expand Down Expand Up @@ -82,41 +83,47 @@ private static void ParseSecurityMetamodel()
}

internal static X509Certificate2? FindServerCertificate(string serverName, out string domain)
{
{
domain = "";
if (GlobalSecurityVariables.ServerCertFileNames != null)
foreach (var entry in GlobalSecurityVariables.TrustedServers)
{
for (var i = 0; i < GlobalSecurityVariables.ServerCertFileNames.Count; i++)
if (entry.Certificate != null && Path.GetFileName(entry.CertFileName) == serverName + ".cer")
{
if (Path.GetFileName(GlobalSecurityVariables.ServerCertFileNames[i]) == serverName + ".cer")
{
domain = GlobalSecurityVariables.ServerDomain[i];
return GlobalSecurityVariables.ServerCertificates[i];
}
domain = entry.Domain;
return entry.Certificate;
}
}

return null;
}

internal static string? FindServerJwksUrl(string kid, string iss, out string domain)
{
domain = "";
if (GlobalSecurityVariables.ServerKid != null)

internal static TrustedServer? FindTrustedJwksEntry(string? kid, string? iss)
=> FindTrustedJwksEntry(GlobalSecurityVariables.TrustedServers, kid, iss);

/// <summary>
/// Finds the trust list entry for a token: first by issuer (iss claim), then by kid header.
/// Empty values never match.
/// </summary>
internal static TrustedServer? FindTrustedJwksEntry(IReadOnlyList<TrustedServer> entries, string? kid, string? iss)
{
var normalizedIss = NormalizeIssuer(iss);
if (normalizedIss != "")
{
for (var i = 0; i < GlobalSecurityVariables.ServerKid.Count; i++)
{
if ((GlobalSecurityVariables.ServerKid[i] != "" &&
GlobalSecurityVariables.ServerKid[i] == kid)
|| GlobalSecurityVariables.ServerIssuerUrl[i] == iss)
{
domain = GlobalSecurityVariables.ServerDomain[i];
return GlobalSecurityVariables.ServerJwksUrl[i];
}
var byIssuer = entries.FirstOrDefault(e => e.Issuer != "" && NormalizeIssuer(e.Issuer) == normalizedIss);
if (byIssuer != null)
{
return byIssuer;
}
}

if (!string.IsNullOrEmpty(kid))
{
return entries.FirstOrDefault(e => e.Kid != "" && e.Kid == kid);
}

return null;
}

internal static string NormalizeIssuer(string? issuer) => (issuer ?? "").Trim().TrimEnd('/');
}
}
Loading
Loading