Plug in external authentication
Wire SmartData up to an external identity provider (OIDC, JWT, SAML, …) by subclassing ExternalTokenValidator. Host owns who; SmartData owns what.
When SmartData's built-in sp_login flow isn't the system of record for users — e.g. you're behind an SSO IdP, or you front the API with a gateway that already mints signed tokens — subclass ExternalTokenValidator and let SmartData do the rest.
The split this enables: the host owns who can log in; SmartData owns what they can do. Each successful resolve upserts a shadow SysUser row keyed by the external id. Permissions are still managed via SmartData's sp_user_permission_* catalog and the embedded admin console — they aren't projected from external claims.
The contract
public abstract class ExternalTokenValidator : ITokenValidator
{
protected ExternalTokenValidator(IDatabaseProvider provider, ExternalTokenValidatorOptions? options = null);
// Implement: validate the token, return the external identity, or null.
protected abstract Task<ExternalIdentity?> ResolveAsync(string token, CancellationToken ct);
}
public sealed record ExternalIdentity(
string ExternalId,
string Username,
DateTime? ExpiresAt = null,
bool IsAdmin = false);
Return null for unknown / expired / malformed tokens — control falls through to the next registered validator. Throw only for transient failures (network, bad config); those propagate as 500-class errors.
The base class handles caching, the shadow-user upsert, the bootstrap admin promotion, and permission loading. You write one method.
Validate a JWT
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using SmartData.Server;
using SmartData.Server.Providers;
public class JwtValidator : ExternalTokenValidator
{
private readonly TokenValidationParameters _params;
public JwtValidator(IDatabaseProvider provider, IConfiguration config)
: base(provider, new ExternalTokenValidatorOptions
{
AutoPromoteFirstUserToAdmin = true,
FallbackCacheTtl = TimeSpan.FromMinutes(5),
})
{
_params = new TokenValidationParameters
{
ValidIssuer = config["Auth:Issuer"],
ValidAudience = config["Auth:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Convert.FromBase64String(config["Auth:SigningKey"]!)),
ValidateLifetime = true,
};
}
protected override Task<ExternalIdentity?> ResolveAsync(string token, CancellationToken ct)
{
try
{
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(token, _params, out var validated);
var jwt = (JwtSecurityToken)validated;
var sub = principal.FindFirst("sub")?.Value
?? throw new SecurityTokenException("missing sub");
var name = principal.FindFirst("preferred_username")?.Value
?? principal.FindFirst("email")?.Value
?? sub;
var isAdmin = principal.IsInRole("smartdata-admin");
return Task.FromResult<ExternalIdentity?>(
new ExternalIdentity(sub, name, jwt.ValidTo, isAdmin));
}
catch (SecurityTokenException)
{
// Bad token, expired, wrong issuer — fall through.
return Task.FromResult<ExternalIdentity?>(null);
}
}
}
Register it
// Order matters: external validator is consulted first when in Mixed mode.
builder.Services.AddSingleton<ITokenValidator, JwtValidator>();
builder.Services.AddSmartData(o =>
{
o.AuthMode = AuthMode.Integrated; // or Mixed, see below
});
Pick the mode that matches your deployment:
| Mode | Behavior | Use when |
|---|---|---|
Database (default) |
Only SmartData sessions and service tokens are accepted. | The IdP is not yet wired up. |
Integrated |
Only external tokens are accepted. sp_login and sp_token_create still exist but their tokens won't validate. |
The IdP is the single source of truth. |
Mixed |
External validator runs first, database validators fall back. | Migrating from local credentials; never the default. |
Startup logs the active mode and the list of registered validators. Integrated with no validators registered fails fast at UseSmartData().
Bootstrap
A fresh database in Integrated mode has no admin and no way to make one through the IdP. Two options, in order of preference:
AutoPromoteFirstUserToAdmin = true(above). The very first user resolved through this validator becomes admin if_sys_usersis empty at the time of the resolve. Bounded by the empty-table check, so it won't fire after bootstrap.- Seed an admin from code before the first integrated login, using
IProcedureServiceandsp_user_create. See Manage users from code.
What the base class does for you
- Caching. Resolved sessions are cached in memory keyed by the token. Cache lifetime is the upstream
ExpiresAt(capped byFallbackCacheTtlwhen null). The cache is not persisted to_sys_sessions, and does not slide — the upstream expiry is the source of truth, so a server restart cannot extend a token past the IdP's revocation point. - Shadow upsert. On every successful resolve, a
SysUserrow keyed byExternalIdis upserted.UsernameandIsAdminare mirrored from the resolved identity, so upstream renames and role changes propagate automatically. - Permission loading. Reads from
SysUserPermissionexactly like local sessions do. Admins implicitly hold every system + scoped permission.
What it doesn't do
- It doesn't sync claims to permissions. That's the point of the shadow-user model — permissions are SmartData's concern, granted via
sp_user_permission_grant. If you want claim-driven permissions, project them yourself by overriding the validator entirely (implementITokenValidatordirectly instead of inheritingExternalTokenValidator). - It doesn't support refresh tokens. Refresh is the host's problem; SmartData only sees the access token via the connection string
Token=...field. - It doesn't multi-tenant out of the box. All shadow users share one
_sys_userstable. Distinguish tenants by prefixingExternalIdwith the tenant id.
Related
- Manage users from code —
IProcedureServicefor seeding - Customize password hashing — the other extension seam in the auth pipeline