Files
DodoSSH/tests/DodoSSH.Api.Tests/StubIdentityProvider.cs
T
jaap-jan 69bc9e270b Let a team be joined only by somebody who is already here
An invitation decided access from an assertion about an address. Everything else
in this model decides it from something a person did — an admin naming an
account, a key holder wrapping a vault key to a key they verified — and this was
the one place a token's email claim was the thing that let somebody in.

It was guarded as tightly as that can be guarded: the claim was refused outright
on an unverified or absent `email_verified`, with no setting to relax it. But the
guard and the risk were the same shape. The whole defence was one boolean sent by
a system the deployment does not control.

So `POST /teams/{id}/members` is the only way in, and an address with no account
is refused with `no-such-account` — which is now the end of the road rather than
the signal to invite. Both clients say the remedy: that person signs in here
once, which is what creates the account, and then they can be added. The desktop
leaves the address in the box, because a message telling you to come back later
is one you act on later.

Gone with it: the `team_invitation` table, the claim hook in the sign-in path,
and `Oidc:EmailVerifiedClaim`, which that hook was the only reader of. Nothing in
the server now reads the email claim to decide anything.

Pending invitations are dropped rather than converted. Converting one would mean
creating a membership because an address matched, which is the property being
removed — and an invitation to an address that did have an account here had
already been claimed by the hourly sweep, so what is left is offers to people who
never arrived.

Two tests carry the property rather than the feature: the endpoint inventory
asserts the three routes are absent, and the API suite adds an address that has
no account, watches the refusal, then signs that address in and checks it joined
nothing. Without the second half, a server that merely renamed the deferred path
would pass.
2026-08-05 08:28:57 +02:00

284 lines
11 KiB
C#

using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.IdentityModel.Tokens;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
using WireMock.Settings;
namespace DodoSSH.Api.Tests;
/// <summary>
/// A stand-in identity provider: OIDC discovery, JWKS, and token minting.
/// </summary>
/// <remarks>
/// Deliberately not a <c>TestAuthHandler</c> that short-circuits authentication. Tokens are signed
/// with a real RSA key and validated by the application's own JwtBearer pipeline, so issuer,
/// audience, signature, lifetime and claim handling are all genuinely exercised. Bypassing that
/// would hide precisely the claim-mapping mistakes that cause real authorization holes.
/// </remarks>
public sealed class StubIdentityProvider : IDisposable
{
private const string KeyId = "dodossh-test-key";
private readonly WireMockServer server;
private readonly RsaSecurityKey signingKey;
public StubIdentityProvider()
{
var rsa = RSA.Create(2048);
signingKey = new RsaSecurityKey(rsa) { KeyId = KeyId };
// Loopback explicitly: WireMock's default listens on every interface, which makes Windows Firewall
// prompt the first time each test executable runs — per binary path, so a new worktree or
// configuration asks again. Port 0 still picks a free port and reports it on server.Url, which is
// what Authority below is built from, so the issuer the tokens claim follows the binding.
server = WireMockServer.Start(new WireMockServerSettings { Urls = ["http://127.0.0.1:0"] });
Authority = server.Url!.TrimEnd('/');
StubDiscovery();
StubJwks();
}
/// <summary>Issuer URL, matching what the tokens claim.</summary>
public string Authority { get; }
/// <summary>Audience the API is configured to expect on access tokens.</summary>
public static string Audience => "dodossh-api";
/// <summary>
/// The public client identifier, and therefore the audience of an ID token.
/// </summary>
/// <remarks>
/// Distinct from <see cref="Audience"/> on purpose. An ID token is audienced to the client that
/// requested it, never to the API — validating a binding token against the API audience would
/// reject every genuine enrollment, and a test that used one audience for both would not notice.
/// </remarks>
public static string ClientId => "dodossh-desktop";
/// <summary>
/// Mints a signed access token.
/// </summary>
/// <param name="subject">The <c>sub</c> claim — the stable user identifier.</param>
/// <param name="email">Optional email claim.</param>
/// <param name="name">Optional display name claim.</param>
/// <param name="audience">Override the audience, to test rejection.</param>
/// <param name="issuer">Override the issuer, to test rejection.</param>
/// <param name="expires">Override expiry, to test rejection.</param>
/// <remarks>
/// <c>email_verified</c> travels beside an address because a real provider sends it, and for no
/// other reason: the server reads it nowhere. It did once — to decide whether a pending team
/// invitation addressed to that address could be claimed — and there are no invitations. Keeping it
/// in the token keeps this stub honest about the shape of a real one.
/// </remarks>
public string MintToken(
string subject,
string? email = null,
string? name = null,
string? audience = null,
string? issuer = null,
DateTime? expires = null)
{
var now = TimeProvider.System.GetUtcNow().UtcDateTime;
var claims = new List<System.Security.Claims.Claim>
{
new("sub", subject),
};
if (email is not null)
{
claims.Add(new System.Security.Claims.Claim("email", email));
// Boolean, so the handler writes a JSON boolean rather than a quoted string, which is what
// a real provider sends.
claims.Add(new System.Security.Claims.Claim(
"email_verified",
"true",
System.Security.Claims.ClaimValueTypes.Boolean));
}
if (name is not null)
{
claims.Add(new System.Security.Claims.Claim("name", name));
}
var expiry = expires ?? now.AddMinutes(15);
// The earlier of now-1min and exp-1min. A fixed now-1min would sit after the expiry of a
// deliberately-expired test token (rejected at construction), while exp-1min alone would
// put nbf in the future for a normal token and make every valid token unauthorized.
var notBefore = expiry < now ? expiry.AddMinutes(-1) : now.AddMinutes(-1);
var token = new JwtSecurityToken(
issuer: issuer ?? Authority,
audience: audience ?? Audience,
claims: claims,
notBefore: notBefore,
expires: expiry,
signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>
/// Mints an ID token carrying a key-binding nonce.
/// </summary>
/// <remarks>
/// This is the artefact the whole public-key trust model rests on: an identity-provider
/// signature over the hash of a key statement. Signed with the same real RSA key the JWKS
/// endpoint publishes, so the server's verification genuinely runs.
/// </remarks>
/// <param name="subject">The <c>sub</c> claim.</param>
/// <param name="nonce">The key statement binding, from <c>KeyStatementCodec.ComputeNonce</c>.</param>
/// <param name="email">Optional email claim.</param>
/// <param name="audience">Override the audience, to test rejection. Defaults to the client id.</param>
/// <param name="issuer">Override the issuer, to test rejection.</param>
/// <param name="expires">Override expiry, to test rejection.</param>
/// <param name="omitNonce">Omit the nonce entirely, to test rejection.</param>
public string MintIdToken(
string subject,
string nonce,
string? email = null,
string? audience = null,
string? issuer = null,
DateTime? expires = null,
bool omitNonce = false)
{
var now = TimeProvider.System.GetUtcNow().UtcDateTime;
// Integer64, so the handler writes a JSON number rather than a string. A provider that
// emitted a string here would be unusual, and the server reads auth_time as a number.
var claims = new List<System.Security.Claims.Claim>
{
new("sub", subject),
new(
"auth_time",
new DateTimeOffset(now, TimeSpan.Zero).ToUnixTimeSeconds()
.ToString(CultureInfo.InvariantCulture),
System.Security.Claims.ClaimValueTypes.Integer64),
};
if (!omitNonce)
{
claims.Add(new System.Security.Claims.Claim("nonce", nonce));
}
if (email is not null)
{
claims.Add(new System.Security.Claims.Claim("email", email));
}
var expiry = expires ?? now.AddMinutes(5);
var notBefore = expiry < now ? expiry.AddMinutes(-1) : now.AddMinutes(-1);
var token = new JwtSecurityToken(
issuer: issuer ?? Authority,
audience: audience ?? ClientId,
claims: claims,
notBefore: notBefore,
expires: expiry,
signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>Mints an ID token signed by a key the provider does not publish.</summary>
public string MintIdTokenWithForeignKey(string subject, string nonce)
{
var foreign = new RsaSecurityKey(RSA.Create(2048)) { KeyId = KeyId };
var now = TimeProvider.System.GetUtcNow().UtcDateTime;
var token = new JwtSecurityToken(
issuer: Authority,
audience: ClientId,
claims:
[
new System.Security.Claims.Claim("sub", subject),
new System.Security.Claims.Claim("nonce", nonce),
],
notBefore: now.AddMinutes(-1),
expires: now.AddMinutes(5),
signingCredentials: new SigningCredentials(foreign, SecurityAlgorithms.RsaSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>Mints a token signed by a different key, which must be rejected.</summary>
public string MintTokenWithForeignKey(string subject)
{
var foreign = new RsaSecurityKey(RSA.Create(2048)) { KeyId = KeyId };
var now = TimeProvider.System.GetUtcNow().UtcDateTime;
var token = new JwtSecurityToken(
issuer: Authority,
audience: Audience,
claims: [new System.Security.Claims.Claim("sub", subject)],
notBefore: now.AddMinutes(-1),
expires: now.AddMinutes(15),
signingCredentials: new SigningCredentials(foreign, SecurityAlgorithms.RsaSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
private void StubDiscovery()
{
var document = new Dictionary<string, object>(StringComparer.Ordinal)
{
["issuer"] = Authority,
["jwks_uri"] = $"{Authority}/.well-known/jwks.json",
["authorization_endpoint"] = $"{Authority}/connect/authorize",
["token_endpoint"] = $"{Authority}/connect/token",
["response_types_supported"] = new[] { "code" },
["subject_types_supported"] = new[] { "public" },
["id_token_signing_alg_values_supported"] = new[] { "RS256" },
["code_challenge_methods_supported"] = new[] { "S256" },
};
server
.Given(Request.Create().WithPath("/.well-known/openid-configuration").UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithBody(JsonSerializer.Serialize(document)));
}
private void StubJwks()
{
var parameters = signingKey.Rsa.ExportParameters(includePrivateParameters: false);
var jwks = new
{
keys = new[]
{
new
{
kty = "RSA",
use = "sig",
kid = KeyId,
alg = "RS256",
n = Base64UrlEncoder.Encode(parameters.Modulus),
e = Base64UrlEncoder.Encode(parameters.Exponent),
},
},
};
server
.Given(Request.Create().WithPath("/.well-known/jwks.json").UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithBody(JsonSerializer.Serialize(jwks)));
}
/// <inheritdoc />
public void Dispose()
{
server.Stop();
server.Dispose();
signingKey.Rsa.Dispose();
}
}