2026-11-07 18:42:43 +00:00
|
|
|
using Bifrost.Web.Features.Shared;
|
2026-10-11 02:54:13 +02:00
|
|
|
using System.Net;
|
|
|
|
|
using System.Security.Cryptography;
|
|
|
|
|
using System.Text;
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
using Bifrost.Web.Data;
|
|
|
|
|
using Bifrost.Web.Features.Heimdall;
|
|
|
|
|
using Microsoft.AspNetCore.Hosting;
|
|
|
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
|
|
|
using Microsoft.AspNetCore.WebUtilities;
|
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
|
using Microsoft.Extensions.Configuration;
|
|
|
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
|
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
|
using Org.BouncyCastle.Math.EC.Rfc8032;
|
|
|
|
|
|
|
|
|
|
namespace Bifrost.Web.Tests.Support;
|
|
|
|
|
|
|
|
|
|
/// <summary>An Ed25519 key shaped like Heimdall's signing key: a "ed25519-" kid over a thumbprint prefix.</summary>
|
|
|
|
|
public sealed class HeimdallKey
|
|
|
|
|
{
|
|
|
|
|
private readonly byte[] _seed;
|
|
|
|
|
|
|
|
|
|
private HeimdallKey(byte[] seed, byte[] publicKey, string kid)
|
|
|
|
|
{
|
|
|
|
|
_seed = seed;
|
|
|
|
|
PublicKey = publicKey;
|
|
|
|
|
Kid = kid;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string Kid { get; }
|
|
|
|
|
|
|
|
|
|
public byte[] PublicKey { get; }
|
|
|
|
|
|
|
|
|
|
public static HeimdallKey Generate(string? kid = null)
|
|
|
|
|
{
|
|
|
|
|
var seed = RandomNumberGenerator.GetBytes(Ed25519.SecretKeySize);
|
|
|
|
|
var publicKey = new byte[Ed25519.PublicKeySize];
|
|
|
|
|
Ed25519.GeneratePublicKey(seed, 0, publicKey, 0);
|
|
|
|
|
kid ??= "ed25519-" + Convert.ToHexString(SHA256.HashData(publicKey)).ToLowerInvariant()[..12];
|
|
|
|
|
return new HeimdallKey(seed, publicKey, kid);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public object Jwk() => new Dictionary<string, object?>
|
|
|
|
|
{
|
|
|
|
|
["kty"] = "OKP",
|
|
|
|
|
["crv"] = "Ed25519",
|
|
|
|
|
["x"] = WebEncoders.Base64UrlEncode(PublicKey),
|
|
|
|
|
["alg"] = "EdDSA",
|
|
|
|
|
["kid"] = Kid,
|
|
|
|
|
["use"] = "sig"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// <summary>Signs like Heimdall's signJwt: header {alg, kid, typ}, compact JWS.</summary>
|
2026-10-11 03:26:48 +02:00
|
|
|
public string Sign(IDictionary<string, object?> claims, string alg = "EdDSA", string? kid = "") =>
|
|
|
|
|
SignParts(HeaderBytes(alg, kid), JsonSerializer.SerializeToUtf8Bytes(claims));
|
|
|
|
|
|
|
|
|
|
public byte[] HeaderBytes(string alg = "EdDSA", string? kid = "")
|
2026-10-11 02:54:13 +02:00
|
|
|
{
|
|
|
|
|
var header = new Dictionary<string, object?> { ["alg"] = alg, ["typ"] = "JWT" };
|
|
|
|
|
if (kid is not null)
|
|
|
|
|
{
|
|
|
|
|
header["kid"] = kid.Length == 0 ? Kid : kid;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 03:26:48 +02:00
|
|
|
return JsonSerializer.SerializeToUtf8Bytes(header);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Signs exactly these header and payload bytes, whatever they hold.</summary>
|
|
|
|
|
public string SignParts(byte[] headerBytes, byte[] payloadBytes)
|
|
|
|
|
{
|
|
|
|
|
var signingInput = WebEncoders.Base64UrlEncode(headerBytes) + "." + WebEncoders.Base64UrlEncode(payloadBytes);
|
2026-10-11 02:54:13 +02:00
|
|
|
var message = Encoding.ASCII.GetBytes(signingInput);
|
|
|
|
|
var signature = new byte[Ed25519.SignatureSize];
|
|
|
|
|
Ed25519.Sign(_seed, 0, message, 0, message.Length, signature, 0);
|
|
|
|
|
return signingInput + "." + WebEncoders.Base64UrlEncode(signature);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static class HeimdallShapes
|
|
|
|
|
{
|
|
|
|
|
public const string Issuer = "https://heimdall.test";
|
|
|
|
|
|
|
|
|
|
public static string NewAccountId() => Guid.NewGuid().ToString();
|
|
|
|
|
|
|
|
|
|
/// <summary>The claim set of Heimdall src/claims.ts issueAccessClaim.</summary>
|
|
|
|
|
public static Dictionary<string, object?> AccessClaims(
|
|
|
|
|
string accountId,
|
|
|
|
|
DateTimeOffset now,
|
|
|
|
|
string[]? capabilities = null,
|
|
|
|
|
string? displayName = "Genuine Name",
|
|
|
|
|
Action<Dictionary<string, object?>>? mutate = null)
|
|
|
|
|
{
|
|
|
|
|
var issuedAt = now.ToUnixTimeSeconds();
|
|
|
|
|
var claims = new Dictionary<string, object?>
|
|
|
|
|
{
|
|
|
|
|
["iss"] = Issuer,
|
|
|
|
|
["aud"] = "bifrost",
|
|
|
|
|
["sub"] = accountId,
|
|
|
|
|
["sid"] = Guid.NewGuid().ToString(),
|
|
|
|
|
["jti"] = Guid.NewGuid().ToString(),
|
|
|
|
|
["iat"] = issuedAt,
|
|
|
|
|
["nbf"] = issuedAt,
|
|
|
|
|
["exp"] = issuedAt + 900,
|
|
|
|
|
["typ"] = "heimdall_access",
|
|
|
|
|
["account_id"] = accountId,
|
|
|
|
|
["access_revision"] = 1,
|
|
|
|
|
["app"] = new Dictionary<string, object?> { ["slug"] = "bifrost", ["profile_version"] = 1 },
|
|
|
|
|
["facts"] = Array.Empty<string>(),
|
|
|
|
|
["capabilities"] = capabilities ?? [],
|
|
|
|
|
["identities"] = new[]
|
|
|
|
|
{
|
|
|
|
|
new Dictionary<string, object?> { ["provider"] = "discord", ["providerUserId"] = "1234567890" }
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if (displayName is not null)
|
|
|
|
|
{
|
|
|
|
|
claims["display_name"] = displayName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mutate?.Invoke(claims);
|
|
|
|
|
return claims;
|
|
|
|
|
}
|
|
|
|
|
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// The body Heimdall's redeem route answers with: the completionPayload of src/app.ts, whose unsigned
|
|
|
|
|
/// fields (loose capabilities, facts, name) Bifrost must not decide anything from.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static Dictionary<string, object?> Completion(
|
|
|
|
|
string returnTo,
|
2026-10-11 02:54:13 +02:00
|
|
|
string accountId,
|
|
|
|
|
string accessToken,
|
|
|
|
|
string[]? looseCapabilities = null,
|
|
|
|
|
string[]? looseFacts = null,
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
string looseName = "Loose Name") => new()
|
2026-10-11 02:54:13 +02:00
|
|
|
{
|
|
|
|
|
["status"] = "success",
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
["provider"] = "discord",
|
2026-10-11 02:54:13 +02:00
|
|
|
["mode"] = "sign_in",
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
["appSlug"] = "bifrost",
|
2026-10-11 02:54:13 +02:00
|
|
|
["account"] = new Dictionary<string, object?> { ["id"] = accountId, ["displayName"] = looseName },
|
|
|
|
|
["entitlements"] = new Dictionary<string, object?>
|
|
|
|
|
{
|
|
|
|
|
["facts"] = looseFacts ?? [],
|
|
|
|
|
["snapshots"] = Array.Empty<string>()
|
|
|
|
|
},
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
["returnTo"] = returnTo,
|
|
|
|
|
["connection"] = null,
|
2026-10-11 02:54:13 +02:00
|
|
|
["session"] = new Dictionary<string, object?> { ["accountId"] = accountId, ["appSlug"] = "bifrost" },
|
|
|
|
|
["accessToken"] = accessToken,
|
|
|
|
|
["refreshToken"] = "refresh-token-not-read",
|
|
|
|
|
["refresh"] = new Dictionary<string, object?> { ["expiresAt"] = "2026-11-11T00:00:00.000Z" },
|
|
|
|
|
["verification"] = new Dictionary<string, object?> { ["issuer"] = Issuer },
|
|
|
|
|
["sharedCapabilities"] = looseCapabilities ?? [],
|
|
|
|
|
["hybridCapabilities"] = Array.Empty<string>()
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public sealed class TestClock : TimeProvider
|
|
|
|
|
{
|
|
|
|
|
private DateTimeOffset _now = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
|
|
|
|
|
|
public DateTimeOffset Now => _now;
|
|
|
|
|
|
|
|
|
|
public void Advance(TimeSpan by) => _now += by;
|
|
|
|
|
|
|
|
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
|
|
|
}
|
|
|
|
|
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// Stands in for Heimdall's HTTP surface: the JWKS, the oauth start endpoint (which, like app.ts, answers
|
|
|
|
|
/// a policy start only when the app caller's secret is present) and the completion-code redeem route.
|
|
|
|
|
/// </summary>
|
2026-10-11 02:54:13 +02:00
|
|
|
public sealed class FakeHeimdall : HttpMessageHandler
|
|
|
|
|
{
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
private readonly Dictionary<string, Dictionary<string, object?>> _codes = new(StringComparer.Ordinal);
|
|
|
|
|
|
2026-10-11 02:54:13 +02:00
|
|
|
public List<HeimdallKey> Published { get; } = [];
|
|
|
|
|
|
|
|
|
|
public List<object> ExtraJwks { get; } = [];
|
|
|
|
|
|
|
|
|
|
public int JwksFetches { get; private set; }
|
|
|
|
|
|
|
|
|
|
public HttpStatusCode JwksStatus { get; set; } = HttpStatusCode.OK;
|
|
|
|
|
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
private TaskCompletionSource? _jwksHold;
|
|
|
|
|
|
|
|
|
|
/// <summary>Completes when a JWKS request arrives after <see cref="HoldJwks"/>.</summary>
|
|
|
|
|
public TaskCompletionSource JwksArrived { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
|
|
|
|
|
|
/// <summary>Holds the next JWKS responses until the returned source completes.</summary>
|
|
|
|
|
public TaskCompletionSource HoldJwks()
|
|
|
|
|
{
|
|
|
|
|
JwksArrived = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
|
return _jwksHold = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Exception? JwksFailure { get; set; }
|
|
|
|
|
|
2026-10-11 02:54:13 +02:00
|
|
|
public List<string> StartBodies { get; } = [];
|
|
|
|
|
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
public List<string?> StartSecrets { get; } = [];
|
|
|
|
|
|
|
|
|
|
/// <summary>The secret a policy start must carry in x-heimdall-app-secret; null means Heimdall asks for none.</summary>
|
|
|
|
|
public string? RequiredAppSecret { get; set; }
|
|
|
|
|
|
2026-10-11 03:26:48 +02:00
|
|
|
public HttpStatusCode StartStatus { get; set; } = HttpStatusCode.OK;
|
|
|
|
|
|
|
|
|
|
public string StartBody { get; set; } =
|
|
|
|
|
"""{"authorizationUrl":"https://heimdall.test/oauth/discord/authorize?state=fixture"}""";
|
|
|
|
|
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
public int RedeemCalls { get; private set; }
|
|
|
|
|
|
|
|
|
|
public HttpStatusCode? RedeemStatus { get; set; }
|
|
|
|
|
|
|
|
|
|
public string? RedeemBody { get; set; }
|
|
|
|
|
|
|
|
|
|
/// <summary>Registers a completion code Heimdall will redeem once, answering with this payload.</summary>
|
|
|
|
|
public string RegisterCode(Dictionary<string, object?> completion)
|
|
|
|
|
{
|
|
|
|
|
var code = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
|
|
|
|
|
_codes[code] = completion;
|
|
|
|
|
return code;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 02:54:13 +02:00
|
|
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var path = request.RequestUri!.AbsolutePath;
|
|
|
|
|
if (request.Method == HttpMethod.Get && path == "/.well-known/jwks.json")
|
|
|
|
|
{
|
|
|
|
|
JwksFetches++;
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
JwksArrived.TrySetResult();
|
|
|
|
|
if (_jwksHold is not null)
|
|
|
|
|
{
|
|
|
|
|
await _jwksHold.Task.WaitAsync(cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (JwksFailure is not null)
|
|
|
|
|
{
|
|
|
|
|
throw JwksFailure;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 02:54:13 +02:00
|
|
|
var keys = Published.Select(x => x.Jwk()).Concat(ExtraJwks).ToArray();
|
|
|
|
|
return new HttpResponseMessage(JwksStatus)
|
|
|
|
|
{
|
|
|
|
|
Content = new StringContent(JsonSerializer.Serialize(new { keys }), Encoding.UTF8, "application/json")
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (request.Method == HttpMethod.Post && path.StartsWith("/v1/oauth/", StringComparison.Ordinal) && path.EndsWith("/start", StringComparison.Ordinal))
|
|
|
|
|
{
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
var body = await request.Content!.ReadAsStringAsync(cancellationToken);
|
|
|
|
|
var secret = request.Headers.TryGetValues("x-heimdall-app-secret", out var values) ? values.Single() : null;
|
|
|
|
|
StartBodies.Add(body);
|
|
|
|
|
StartSecrets.Add(secret);
|
|
|
|
|
var wantsPolicy = JsonDocument.Parse(body).RootElement.TryGetProperty("entitlementPolicy", out _);
|
|
|
|
|
if (wantsPolicy && RequiredAppSecret is not null && secret != RequiredAppSecret)
|
|
|
|
|
{
|
|
|
|
|
return new HttpResponseMessage(HttpStatusCode.Unauthorized)
|
|
|
|
|
{
|
|
|
|
|
Content = new StringContent("""{"error":"app_auth_required"}""", Encoding.UTF8, "application/json")
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 03:26:48 +02:00
|
|
|
return new HttpResponseMessage(StartStatus)
|
2026-10-11 02:54:13 +02:00
|
|
|
{
|
2026-10-11 03:26:48 +02:00
|
|
|
Content = new StringContent(StartBody, Encoding.UTF8, "application/json")
|
2026-10-11 02:54:13 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/auth-completions/redeem")
|
|
|
|
|
{
|
|
|
|
|
RedeemCalls++;
|
|
|
|
|
if (RedeemStatus is { } status)
|
|
|
|
|
{
|
|
|
|
|
return new HttpResponseMessage(status)
|
|
|
|
|
{
|
|
|
|
|
Content = new StringContent(RedeemBody ?? "{}", Encoding.UTF8, "application/json")
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var code = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken))
|
|
|
|
|
.RootElement.GetProperty("completionCode").GetString()!;
|
|
|
|
|
if (!_codes.Remove(code, out var completion))
|
|
|
|
|
{
|
|
|
|
|
return new HttpResponseMessage(HttpStatusCode.Gone)
|
|
|
|
|
{
|
|
|
|
|
Content = new StringContent("""{"error":"invalid_or_expired_completion_code"}""", Encoding.UTF8, "application/json")
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new HttpResponseMessage(HttpStatusCode.Created)
|
|
|
|
|
{
|
|
|
|
|
Content = new StringContent(RedeemBody ?? JsonSerializer.Serialize(completion), Encoding.UTF8, "application/json")
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 02:54:13 +02:00
|
|
|
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public sealed class CapturedLogs : ILoggerProvider
|
|
|
|
|
{
|
|
|
|
|
private readonly List<string> _lines = [];
|
|
|
|
|
|
|
|
|
|
public IReadOnlyList<string> Lines
|
|
|
|
|
{
|
|
|
|
|
get
|
|
|
|
|
{
|
|
|
|
|
lock (_lines)
|
|
|
|
|
{
|
|
|
|
|
return _lines.ToArray();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public ILogger CreateLogger(string categoryName) => new Capture(this);
|
|
|
|
|
|
|
|
|
|
public void Dispose()
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class Capture(CapturedLogs owner) : ILogger
|
|
|
|
|
{
|
|
|
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
|
|
|
|
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
|
|
|
|
|
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
|
|
|
|
{
|
|
|
|
|
lock (owner._lines)
|
|
|
|
|
{
|
|
|
|
|
owner._lines.Add(formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
/// <summary>An attempt Start issued: its nonce, the cookie it set, and the returnTo it sent Heimdall.</summary>
|
2026-10-11 22:25:24 +02:00
|
|
|
public sealed record StartedAttempt(string Nonce, string? CookieName, string? Cookie, string ReturnTo, HttpResponseMessage Response);
|
2026-10-11 02:54:13 +02:00
|
|
|
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
public sealed record CompletionResult(HttpResponseMessage Response, bool SignedIn);
|
2026-10-11 02:54:13 +02:00
|
|
|
|
|
|
|
|
/// <summary>A local Bifrost with Heimdall faked in-process: a test JWKS, a test clock, captured logs.</summary>
|
|
|
|
|
public sealed class HeimdallHost : TestWebApplicationFactory
|
|
|
|
|
{
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
public const string AppSecret = "test-heimdall-app-secret";
|
|
|
|
|
|
|
|
|
|
private readonly string _appSecret;
|
2026-10-11 08:58:19 +02:00
|
|
|
private readonly string _appSlug;
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
private readonly string _baseUrl;
|
2026-10-11 05:25:14 +02:00
|
|
|
private readonly string _publicBaseUrl;
|
|
|
|
|
private readonly bool _discordRoles;
|
2026-10-11 02:54:13 +02:00
|
|
|
|
|
|
|
|
public TestClock Clock { get; } = new();
|
|
|
|
|
|
|
|
|
|
public FakeHeimdall Heimdall { get; } = new();
|
|
|
|
|
|
|
|
|
|
public CapturedLogs Logs { get; } = new();
|
|
|
|
|
|
|
|
|
|
public HeimdallKey Key { get; } = HeimdallKey.Generate();
|
|
|
|
|
|
2026-10-11 05:25:14 +02:00
|
|
|
public HeimdallHost(
|
|
|
|
|
string appSecret = AppSecret,
|
|
|
|
|
string baseUrl = HeimdallShapes.Issuer,
|
|
|
|
|
string publicBaseUrl = "https://localhost",
|
2026-10-11 08:58:19 +02:00
|
|
|
bool discordRoles = true,
|
|
|
|
|
string appSlug = "bifrost")
|
2026-10-11 02:54:13 +02:00
|
|
|
{
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
_appSecret = appSecret;
|
2026-10-11 08:58:19 +02:00
|
|
|
_appSlug = appSlug;
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
_baseUrl = baseUrl;
|
2026-10-11 05:25:14 +02:00
|
|
|
_publicBaseUrl = publicBaseUrl;
|
|
|
|
|
_discordRoles = discordRoles;
|
2026-10-11 02:54:13 +02:00
|
|
|
Heimdall.Published.Add(Key);
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
Heimdall.RequiredAppSecret = AppSecret;
|
2026-10-11 02:54:13 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
|
|
|
|
{
|
|
|
|
|
base.ConfigureWebHost(builder);
|
2026-10-11 04:42:50 +02:00
|
|
|
// The GitHub OAuth scheme is registered from configuration read while the app builds, which only host settings reach.
|
|
|
|
|
builder.UseSetting("GitHubOAuth:ClientId", "configured-for-tests");
|
|
|
|
|
builder.UseSetting("GitHubOAuth:ClientSecret", "configured-for-tests");
|
2026-10-11 02:54:13 +02:00
|
|
|
builder.ConfigureAppConfiguration((_, configuration) =>
|
|
|
|
|
{
|
|
|
|
|
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
|
|
|
|
{
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
["Heimdall:BaseUrl"] = _baseUrl,
|
|
|
|
|
["Heimdall:AppSecret"] = _appSecret,
|
2026-10-11 08:58:19 +02:00
|
|
|
["Heimdall:AppSlug"] = _appSlug,
|
2026-10-11 02:54:13 +02:00
|
|
|
["Heimdall:DiscordGuildId"] = "guild-1",
|
2026-10-11 05:25:14 +02:00
|
|
|
["Host:PublicBaseUrl"] = _publicBaseUrl
|
2026-10-11 02:54:13 +02:00
|
|
|
});
|
2026-10-11 05:25:14 +02:00
|
|
|
if (_discordRoles)
|
|
|
|
|
{
|
|
|
|
|
configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["Heimdall:DiscordAllowedRoleIds:0"] = "role-1" });
|
|
|
|
|
}
|
2026-10-11 02:54:13 +02:00
|
|
|
});
|
|
|
|
|
builder.ConfigureLogging(logging => logging.AddProvider(Logs));
|
|
|
|
|
builder.ConfigureServices(services =>
|
|
|
|
|
{
|
|
|
|
|
services.RemoveAll<TimeProvider>();
|
|
|
|
|
services.AddSingleton<TimeProvider>(Clock);
|
2026-10-11 02:55:35 +02:00
|
|
|
services.AddHttpClient(HeimdallTokenVerifier.ClientName).ConfigurePrimaryHttpMessageHandler(() => Heimdall);
|
2026-10-11 02:54:13 +02:00
|
|
|
services.AddHttpClient<HeimdallAuthService>().ConfigurePrimaryHttpMessageHandler(() => Heimdall);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public HttpClient NewClient() => CreateClient(new WebApplicationFactoryClientOptions
|
|
|
|
|
{
|
|
|
|
|
AllowAutoRedirect = false,
|
|
|
|
|
HandleCookies = false,
|
|
|
|
|
BaseAddress = new Uri("https://localhost")
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
public string Token(string accountId, string[]? capabilities = null, string? displayName = "Genuine Name",
|
|
|
|
|
Action<Dictionary<string, object?>>? mutate = null, HeimdallKey? signer = null) =>
|
|
|
|
|
(signer ?? Key).Sign(HeimdallShapes.AccessClaims(accountId, Clock.Now, capabilities, displayName, mutate));
|
|
|
|
|
|
2026-10-11 20:41:41 +02:00
|
|
|
public Task<StartedAttempt> StartAsync(
|
|
|
|
|
bool member = false, string provider = "discord", string returnUrl = "/App/Mill", string? cookieHeader = null) =>
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
StartAtAsync(
|
2026-10-11 20:41:41 +02:00
|
|
|
$"/auth/heimdall/{provider}?returnUrl={Uri.EscapeDataString(returnUrl)}" + (member ? "&access=member" : string.Empty),
|
|
|
|
|
cookieHeader);
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
|
2026-10-11 20:41:41 +02:00
|
|
|
public async Task<StartedAttempt> StartAtAsync(string url, string? cookieHeader = null)
|
2026-10-11 02:54:13 +02:00
|
|
|
{
|
|
|
|
|
using var client = NewClient();
|
|
|
|
|
var before = Heimdall.StartBodies.Count;
|
2026-10-11 20:41:41 +02:00
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
|
|
|
if (cookieHeader is not null)
|
|
|
|
|
{
|
|
|
|
|
request.Headers.Add("Cookie", cookieHeader);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var response = await client.SendAsync(request);
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
if (Heimdall.StartBodies.Count == before)
|
|
|
|
|
{
|
2026-10-11 22:25:24 +02:00
|
|
|
return new StartedAttempt(string.Empty, null, null, string.Empty, response);
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var returnTo = JsonDocument.Parse(Heimdall.StartBodies[^1]).RootElement.GetProperty("returnTo").GetString()!;
|
|
|
|
|
var nonce = QueryHelpers.ParseQuery(new Uri(returnTo).Query)["attempt"].ToString();
|
2026-10-11 22:25:24 +02:00
|
|
|
var slot = HeimdallAttemptCookie.SlotNames.FirstOrDefault(x => CookieValue(response, x) is { Length: > 0 });
|
|
|
|
|
return new StartedAttempt(nonce, slot, slot is null ? null : CookieValue(response, slot), returnTo, response);
|
2026-10-11 02:54:13 +02:00
|
|
|
}
|
|
|
|
|
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
/// <summary>Registers a completion code as Heimdall would mint it after authenticating this attempt.</summary>
|
|
|
|
|
public string CodeFor(StartedAttempt attempt, string accountId, string? accessToken = null, string[]? looseCapabilities = null,
|
|
|
|
|
string[]? looseFacts = null, string looseName = "Loose Name", string? returnTo = null)
|
2026-10-11 02:54:13 +02:00
|
|
|
{
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
var completion = HeimdallShapes.Completion(
|
|
|
|
|
returnTo ?? attempt.ReturnTo, accountId, accessToken ?? Token(accountId), looseCapabilities, looseFacts, looseName);
|
|
|
|
|
return Heimdall.RegisterCode(completion);
|
2026-10-11 02:54:13 +02:00
|
|
|
}
|
|
|
|
|
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
/// <summary>The browser's POST from the complete page: the attempt and code in a form, the attempt cookie by name.</summary>
|
2026-10-11 22:25:24 +02:00
|
|
|
public Task<CompletionResult> CompleteAsync(string? nonce, string? code, string? cookie, string? cookieName = null) =>
|
|
|
|
|
CompleteWithHeaderAsync(
|
|
|
|
|
nonce, code, cookie is null ? null : $"{cookieName ?? HeimdallAttemptCookie.SlotNames[0]}={cookie}");
|
|
|
|
|
|
|
|
|
|
/// <summary>The same POST carrying a browser's whole Cookie header (a jar), not one attempt cookie.</summary>
|
|
|
|
|
public async Task<CompletionResult> CompleteWithHeaderAsync(string? nonce, string? code, string? cookieHeader)
|
2026-10-11 02:54:13 +02:00
|
|
|
{
|
|
|
|
|
using var client = NewClient();
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
var fields = new Dictionary<string, string>();
|
|
|
|
|
if (nonce is not null)
|
2026-10-11 02:54:13 +02:00
|
|
|
{
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
fields["attempt"] = nonce;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code is not null)
|
|
|
|
|
{
|
|
|
|
|
fields["code"] = code;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/heimdall/complete") { Content = new FormUrlEncodedContent(fields) };
|
2026-10-11 22:25:24 +02:00
|
|
|
if (cookieHeader is not null)
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
{
|
2026-10-11 22:25:24 +02:00
|
|
|
request.Headers.Add("Cookie", cookieHeader);
|
2026-10-11 02:54:13 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var response = await client.SendAsync(request);
|
2026-11-07 18:42:43 +00:00
|
|
|
var session = CookieValue(response, BifrostCookies.Session);
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
return new CompletionResult(response, !string.IsNullOrEmpty(session));
|
2026-10-11 02:54:13 +02:00
|
|
|
}
|
|
|
|
|
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
public Task<CompletionResult> CompleteAsync(StartedAttempt attempt, string code) =>
|
2026-10-11 22:25:24 +02:00
|
|
|
CompleteAsync(attempt.Nonce, code, attempt.Cookie, attempt.CookieName);
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
|
2026-10-11 02:54:13 +02:00
|
|
|
public static string? CookieValue(HttpResponseMessage response, string name)
|
|
|
|
|
{
|
|
|
|
|
if (!response.Headers.TryGetValues("Set-Cookie", out var headers))
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var prefix = name + "=";
|
|
|
|
|
var header = headers.FirstOrDefault(x => x.StartsWith(prefix, StringComparison.Ordinal));
|
|
|
|
|
return header?[prefix.Length..].Split(';')[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static string? SetCookieHeader(HttpResponseMessage response, string name) =>
|
|
|
|
|
response.Headers.TryGetValues("Set-Cookie", out var headers)
|
|
|
|
|
? headers.FirstOrDefault(x => x.StartsWith(name + "=", StringComparison.Ordinal))
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
public async Task<T> Db<T>(Func<BifrostDbContext, Task<T>> query)
|
|
|
|
|
{
|
|
|
|
|
using var scope = Services.CreateScope();
|
|
|
|
|
return await query(scope.ServiceProvider.GetRequiredService<BifrostDbContext>());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task<Bifrost.Web.Domain.UserAccount?> Account(string heimdallAccountId) =>
|
|
|
|
|
Db(db => db.UserAccounts
|
|
|
|
|
.Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments)
|
|
|
|
|
.SingleOrDefaultAsync(x => x.HeimdallAccountId == heimdallAccountId));
|
|
|
|
|
|
|
|
|
|
public Task<int> AccountCount() => Db(db => db.UserAccounts.CountAsync());
|
|
|
|
|
}
|