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:

  1. AutoPromoteFirstUserToAdmin = true (above). The very first user resolved through this validator becomes admin if _sys_users is empty at the time of the resolve. Bounded by the empty-table check, so it won't fire after bootstrap.
  2. Seed an admin from code before the first integrated login, using IProcedureService and sp_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 by FallbackCacheTtl when 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 SysUser row keyed by ExternalId is upserted. Username and IsAdmin are mirrored from the resolved identity, so upstream renames and role changes propagate automatically.
  • Permission loading. Reads from SysUserPermission exactly 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 (implement ITokenValidator directly instead of inheriting ExternalTokenValidator).
  • 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_users table. Distinguish tenants by prefixing ExternalId with the tenant id.