returnUrl) that reads the outcome back. There is nothing to receive — Authentica never calls you.
1. Configuration
appsettings.json (or environment variables):
{
"Authentica": {
"BaseUrl": "https://authentica-merge-integration.t2.sa",
"ClientId": "YOUR_CLIENT_ID",
"ClientSecret": "YOUR_CLIENT_SECRET",
"ReturnUrl": "https://yourapp.com/verify/done"
}
}
ReturnUrl in the portal, matched as a whole string, and send exactly that value on /session.
builder.Services.AddHttpClient("Authentica", (sp, client) =>
{
var cfg = sp.GetRequiredService<IConfiguration>().GetSection("Authentica");
client.BaseAddress = new Uri(cfg["BaseUrl"]!.TrimEnd('/') + "/");
});
// Pending actions are keyed by `state`, and carry the sessionId you read the outcome with.
builder.Services.AddDistributedMemoryCache();
2. Start verify — POST /verify/start
[ApiController]
public class VerifyStartController : ControllerBase
{
private readonly IHttpClientFactory _http;
private readonly IConfiguration _cfg;
private readonly IDistributedCache _cache;
public VerifyStartController(IHttpClientFactory http, IConfiguration cfg, IDistributedCache cache)
{
_http = http; _cfg = cfg; _cache = cache;
}
// Whatever the action needs. "Action" is your own enum/string: login, payment, phone-change, …
public record StartVerifyRequest(string Action, string Destination);
// What is being verified. Stored under `state`.
public record Pending(string UserRef, string Action, string SessionId, bool Done);
[HttpPost("/verify/start")]
public async Task<IActionResult> Start([FromBody] StartVerifyRequest body, CancellationToken ct)
{
// 1) YOUR checks for this action (password, balance, permissions, ownership …)
var userRef = "user-123"; // stable id — same value every time
var channel = "sms"; // SMS-only — set on the server
var operationSensitivity = "normal"; // your backend decides; "sensitive" forces OTP
var state = Guid.NewGuid().ToString("N"); // opaque, single use, carries no meaning
var section = _cfg.GetSection("Authentica");
var basic = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(
$"{section["ClientId"]}:{section["ClientSecret"]}"));
var client = _http.CreateClient("Authentica");
using var req = new HttpRequestMessage(HttpMethod.Post, "api/V1/Verify/session");
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", basic);
// A timeout retry must not text the user twice.
req.Headers.Add("Idempotency-Key", state);
req.Content = JsonContent.Create(new
{
userRef,
destination = body.Destination,
channel,
state,
returnUrl = section["ReturnUrl"],
operationSensitivity
});
using var res = await client.SendAsync(req, ct);
var json = await res.Content.ReadFromJsonAsync<AuthenticaEnvelope<SessionResult>>(ct);
if (json is null || !json.IsSuccess || json.Result?.VerifyUrl is null || json.Result?.Handoff is null)
return StatusCode(502, new { error = json?.ErrorCode ?? "session_failed" });
// Remember WHAT is being verified, and the sessionId you will read the outcome with.
var pending = JsonSerializer.Serialize(
new Pending(userRef, body.Action, json.Result.SessionId, false));
await _cache.SetStringAsync($"otp:state:{state}", pending,
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15) }, ct);
// Both values go to the browser: it POSTs `handoff` to `verifyUrl` as the `hx` field.
// `handoff` is single-use with a 120 s TTL — never put it in a URL.
return Ok(new { verifyUrl = json.Result.VerifyUrl, handoff = json.Result.Handoff });
}
}
public class AuthenticaEnvelope<T>
{
public T? Result { get; set; }
public bool IsSuccess { get; set; }
public int StatusCode { get; set; }
public string? ErrorCode { get; set; }
public string? ErrorMessage { get; set; }
}
public class SessionResult
{
public string SessionId { get; set; } = "";
public string VerifyUrl { get; set; } = "";
public string Handoff { get; set; } = "";
public string HandoffField { get; set; } = "hx";
public int ExpiresInSeconds { get; set; }
}
public class SessionStatus
{
public string SessionId { get; set; } = "";
public string Status { get; set; } = "pending"; // pending | verified | failed | expired
public string? UserRef { get; set; } // only when verified
public string? Method { get; set; }
public string? State { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
// `assertionExchanged` is also on the wire. It is a legacy field — do not map it, do not read it.
}
3. Landing route — GET /verify/done
The registered ReturnUrl. A plain GET: normal SameSite=Lax cookies arrive, no CSRF exemption needed, nothing secret on the URL. This is where the verification is actually finished.
session_id and state are inputs to a lookup, not evidence. Nothing here is trusted until GET /session/{id} — authenticated with your client credentials — says so.[ApiController]
public class VerifyDoneController : ControllerBase
{
private readonly IHttpClientFactory _http;
private readonly IConfiguration _cfg;
private readonly IDistributedCache _cache;
private readonly IFulfilment _fulfilment; // your onVerified(sessionId) lives here
public VerifyDoneController(IHttpClientFactory http, IConfiguration cfg,
IDistributedCache cache, IFulfilment fulfilment)
{
_http = http; _cfg = cfg; _cache = cache; _fulfilment = fulfilment;
}
[HttpGet("/verify/done")]
public async Task<IActionResult> Done(
[FromQuery(Name = "session_id")] string? sessionId,
[FromQuery] string? state,
CancellationToken ct)
{
// 1) Resolve YOUR pending record. No record → nothing to finish.
var raw = string.IsNullOrEmpty(state) ? null : await _cache.GetStringAsync($"otp:state:{state}", ct);
var pending = raw is null ? null : JsonSerializer.Deserialize<VerifyStartController.Pending>(raw);
if (pending is null) return SeeOther("/verify/failed");
// Already fulfilled (a refresh, or the sweep got there first) — idempotent, just land them.
if (pending.Done) return SeeOther("/verify/success");
// 2) Ask Authentica what happened. Use the sessionId from YOUR record, not the URL.
var lookupId = string.IsNullOrWhiteSpace(pending.SessionId) ? (sessionId ?? "") : pending.SessionId;
var status = await GetSessionStatusAsync(lookupId, ct);
// 3) Three things must agree: verified, our state, our user.
if (status is null
|| !string.Equals(status.Status, "verified", StringComparison.Ordinal)
|| !string.Equals(status.State, state, StringComparison.Ordinal)
|| !string.Equals(status.UserRef, pending.UserRef, StringComparison.Ordinal))
{
return SeeOther("/verify/failed");
}
// 4) Fulfil once, then set YOUR OWN session cookie — this is the point of the whole flow.
await _fulfilment.OnVerifiedAsync(status.SessionId, pending.Action, pending.UserRef, ct);
await _cache.SetStringAsync($"otp:state:{state}",
JsonSerializer.Serialize(pending with { Done = true }),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15) }, ct);
await HttpContext.SignInAsync(BuildPrincipalFor(pending.UserRef)); // your own auth
return SeeOther("/verify/success");
}
private async Task<SessionStatus?> GetSessionStatusAsync(string sessionId, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(sessionId)) return null;
var section = _cfg.GetSection("Authentica");
var basic = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(
$"{section["ClientId"]}:{section["ClientSecret"]}"));
var client = _http.CreateClient("Authentica");
using var req = new HttpRequestMessage(HttpMethod.Get,
$"api/V1/Verify/session/{Uri.EscapeDataString(sessionId)}");
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", basic);
using var res = await client.SendAsync(req, ct);
var json = await res.Content.ReadFromJsonAsync<AuthenticaEnvelope<SessionStatus>>(ct);
return json?.IsSuccess == true ? json.Result : null;
}
// 303 See Other — never 302 or 307.
private IActionResult SeeOther(string url)
{
Response.Headers["Location"] = url;
return StatusCode(StatusCodes.Status303SeeOther);
}
}
IFulfilment.OnVerifiedAsync must be idempotent: perform the action and mark your record used in one transaction. The landing page can be hit twice, and your reconciliation sweep calls the same method.
4. The user who never came back
No redirect fires if the tab is closed. Sweep your own pending records — oneGET /session/{id} each, inside the 24 h retention window — and feed verified results through the same OnVerifiedAsync:
foreach (var p in await _pending.ListUnfinishedAsync(ct))
{
var status = await GetSessionStatusAsync(p.SessionId, ct);
if (status?.Status == "verified" && status.UserRef == p.UserRef)
await _fulfilment.OnVerifiedAsync(status.SessionId, p.Action, p.UserRef, ct);
}
5. Connect the frontend
const { verifyUrl, handoff } = await fetch("/verify/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ action: "payment", destination: "0501234567" }),
}).then((r) => r.json());
// POST into the hosted page — never navigate to verifyUrl.
const f = document.createElement("form");
f.method = "POST";
f.action = verifyUrl; // as returned — do not append anything
f.style.display = "none";
const i = document.createElement("input");
i.type = "hidden";
i.name = "hx";
i.value = handoff;
f.appendChild(i);
document.body.appendChild(f);
f.submit();
/verify/done, and your server finds out what happened.
More: Session status · Frontend overview.
