Regenerate the secret

This commit is contained in:
Paul Schneider 2026-06-19 01:36:08 +01:00
commit 19cf073677
10 changed files with 199 additions and 16 deletions

View file

@ -194,5 +194,104 @@ namespace Yavsc.Controllers
await dbContext.SaveChangesAsync();
return RedirectToAction("Index");
}
// GET: Client/RegenerateSecret/5
[ActionName("RegenerateSecret")]
public async Task<IActionResult> RegenerateSecretGet(int id)
{
Client client = await dbContext.Clients
.Include(c => c.ClientSecrets)
.SingleOrDefaultAsync(m => m.Id == id);
if (client == null)
{
return NotFound();
}
return View("RegenerateSecret", client);
}
// POST: Client/RegenerateSecret/5
[HttpPost, ActionName("RegenerateSecret")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RegenerateSecretConfirmed(int id)
{
Client client = await dbContext.Clients
.Include(c => c.ClientSecrets)
.SingleOrDefaultAsync(m => m.Id == id);
if (client == null)
{
return NotFound();
}
// Generate a fresh secret in clear text. We display it to the admin
// once, then IdentityServer will hash it on SaveChanges.
var newSecret = GenerateRawClientSecret();
var now = DateTime.UtcNow;
var expiration = now.AddDays(90);
// Replace: drop existing secrets and add the freshly generated one.
if (client.ClientSecrets != null && client.ClientSecrets.Count > 0)
{
dbContext.ClientSecrets.RemoveRange(client.ClientSecrets);
}
client.ClientSecrets = new List<ClientSecret>
{
new ClientSecret
{
ClientId = client.Id,
Client = client,
Type = "SharedSecret",
Value = newSecret,
Description = $"Regenerated on {now:yyyy-MM-dd HH:mm:ss} UTC",
Created = now,
Expiration = expiration
}
};
dbContext.Update(client);
await dbContext.SaveChangesAsync(User.GetUserId());
// Flash the secret through TempData so the next request can render
// it exactly once, then it is gone forever (IdentityServer stores
// it hashed).
TempData["NewClientSecret"] = newSecret;
TempData["NewClientSecretExpiresAt"] = expiration.ToString("u");
TempData["NewClientSecretClientName"] = client.ClientName ?? client.ClientId;
return RedirectToAction("ShowSecret", new { id = client.Id });
}
// GET: Client/ShowSecret/5
public IActionResult ShowSecret(int id)
{
var secret = TempData["NewClientSecret"] as string;
if (string.IsNullOrEmpty(secret))
{
// The one-shot window is closed. Refuse to render anything
// sensitive and bounce back to the details page.
return RedirectToAction("Details", new { id });
}
// TempData.Keep would persist to the next request; we deliberately
// do NOT keep it so the value cannot be replayed.
ViewBag.NewClientSecret = secret;
ViewBag.NewClientSecretExpiresAt = TempData["NewClientSecretExpiresAt"] as string;
ViewBag.NewClientSecretClientName = TempData["NewClientSecretClientName"] as string;
ViewBag.ClientId = id;
return View();
}
private static string GenerateRawClientSecret()
{
// 32 bytes => 43 url-safe base64 chars without padding. Enough entropy
// for a client secret; readable enough to copy/paste once.
var bytes = new byte[32];
System.Security.Cryptography.RandomNumberGenerator.Fill(bytes);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}
}

View file

@ -66,5 +66,6 @@
</div>
<p>
<a asp-action="Edit" asp-route-id="@Model.Id">@Localizer["Edit"]</a> |
<a asp-action="RegenerateSecret" asp-route-id="@Model.Id">@Localizer["Regenerate secret"]</a> |
<a asp-action="Index">@Localizer["Back to List"]</a>
</p>

View file

@ -0,0 +1,39 @@
@model Client
<h2>@Localizer["Regenerate client secret"]</h2>
<div class="alert alert-warning" role="alert">
<strong>@Localizer["Warning."]</strong>
@Localizer["Regenerating the secret will immediately invalidate the current one. Any client using the existing secret will be unable to authenticate until it is updated with the new value. The new secret will be displayed exactly once and cannot be recovered afterwards."]
</div>
<div>
<h4>@Model.ClientName</h4>
<hr />
<dl class="dl-horizontal">
<dt>@Html.DisplayNameFor(model => model.ClientId)</dt>
<dd>@Html.DisplayFor(model => model.ClientId)</dd>
<dt>@Localizer["Current secrets"]</dt>
<dd>
@if (Model.ClientSecrets != null && Model.ClientSecrets.Count > 0)
{
<ul>
@foreach (var secret in Model.ClientSecrets)
{
<li>@secret.Description <small class="text-muted">(created @secret.Created.ToString("u"))</small></li>
}
</ul>
}
else
{
<em>@Localizer["No secret currently set."]</em>
}
</dd>
</dl>
</div>
<form asp-action="RegenerateSecret" asp-route-id="@Model.Id" method="post">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-danger">@Localizer["Regenerate now"]</button>
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-default">@Localizer["Cancel"]</a>
</form>

View file

@ -0,0 +1,53 @@
@{
var secret = ViewBag.NewClientSecret as string;
var expiresAt = ViewBag.NewClientSecretExpiresAt as string;
var clientName = ViewBag.NewClientSecretClientName as string;
var clientId = ViewBag.ClientId;
}
<h2>@Localizer["New client secret"]</h2>
<div class="alert alert-danger" role="alert">
<strong>@Localizer["This is the only time this secret will be displayed."]</strong>
@Localizer["Copy it now and store it in a safe place. After you leave this page, the value cannot be recovered — only its hash is stored on the server."]
</div>
<div>
<h4>@clientName</h4>
<hr />
<dl class="dl-horizontal">
<dt>@Localizer["Client identifier"]</dt>
<dd>@clientId</dd>
<dt>@Localizer["Expires at"]</dt>
<dd>@expiresAt</dd>
<dt>@Localizer["Secret value"]</dt>
<dd>
<pre id="newSecretValue" class="pre-scrollable" style="user-select: all;">@secret</pre>
<button type="button" class="btn btn-default" onclick="copySecret()">@Localizer["Copy"]</button>
<span id="copyFeedback" class="text-success" style="display:none;">@Localizer["Copied!"]</span>
</dd>
</dl>
</div>
<p>
<a asp-action="Details" asp-route-id="@clientId" class="btn btn-primary">@Localizer["I have saved the secret — return to client"]</a>
</p>
<script>
function copySecret() {
var text = document.getElementById('newSecretValue').innerText;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () {
var fb = document.getElementById('copyFeedback');
fb.style.display = 'inline';
setTimeout(function () { fb.style.display = 'none'; }, 2000);
});
} else {
// Fallback for browsers without clipboard API
var range = document.createRange();
range.selectNode(document.getElementById('newSecretValue'));
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
}
}
</script>

View file

@ -82,7 +82,10 @@
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:3002"
"Url": "http://localhost:3003"
},
"Https": {
"Url": "https://localhost:3004"
}
}
}