/ GUNRPG.Infrastructure / Identity / WebAuthnService.cs
WebAuthnService.cs
  1  using System.Text;
  2  using System.Text.Json;
  3  using GUNRPG.Application.Identity;
  4  using GUNRPG.Application.Identity.Dtos;
  5  using GUNRPG.Application.Results;
  6  using GUNRPG.Core.Identity;
  7  using Fido2NetLib;
  8  using Fido2NetLib.Objects;
  9  using Microsoft.AspNetCore.Identity;
 10  using Microsoft.Extensions.Options;
 11  
 12  namespace GUNRPG.Infrastructure.Identity;
 13  
 14  /// <summary>
 15  /// WebAuthn registration and authentication service backed by Fido2NetLib.
 16  /// Handles challenge generation, credential verification, signature counter tracking,
 17  /// and replay protection.
 18  ///
 19  /// Origin validation is performed during startup via options validation.
 20  /// </summary>
 21  public sealed class WebAuthnService : IWebAuthnService
 22  {
 23      private readonly IFido2 _fido2;
 24      private readonly Fido2Configuration _fido2Config;
 25      private readonly LiteDbWebAuthnStore _store;
 26      private readonly UserManager<ApplicationUser> _userManager;
 27  
 28      public WebAuthnService(
 29          IFido2 fido2,
 30          IOptions<Fido2Configuration> fido2Config,
 31          LiteDbWebAuthnStore store,
 32          UserManager<ApplicationUser> userManager)
 33      {
 34          _fido2 = fido2;
 35          _fido2Config = fido2Config.Value;
 36          _store = store;
 37          _userManager = userManager;
 38      }
 39  
 40      // ── Registration ─────────────────────────────────────────────────────────
 41  
 42      public async Task<ServiceResult<string>> BeginRegistrationAsync(string username, CancellationToken ct = default)
 43      {
 44          if (string.IsNullOrWhiteSpace(username))
 45              return Err(WebAuthnErrorCode.InvalidRequest, "Username is required.");
 46  
 47          var user = await _userManager.FindByNameAsync(username);
 48          if (user is null)
 49          {
 50              // Create the user on first WebAuthn registration
 51              user = new ApplicationUser
 52              {
 53                  UserName = username,
 54                  AccountId = Guid.NewGuid(),
 55              };
 56              var result = await _userManager.CreateAsync(user);
 57              if (!result.Succeeded)
 58                  return Err(WebAuthnErrorCode.InternalError,
 59                      string.Join("; ", result.Errors.Select(e => e.Description)));
 60          }
 61  
 62          var existingCredentials = _store.GetCredentialsByUserId(user.Id)
 63              .Select(c => new PublicKeyCredentialDescriptor(Base64UrlDecode(c.Id)))
 64              .ToList();
 65  
 66          var fido2User = new Fido2User
 67          {
 68              Id = Encoding.UTF8.GetBytes(user.Id),
 69              Name = username,
 70              DisplayName = username,
 71          };
 72  
 73          var options = _fido2.RequestNewCredential(new RequestNewCredentialParams
 74          {
 75              User = fido2User,
 76              ExcludeCredentials = existingCredentials,
 77              AuthenticatorSelection = AuthenticatorSelection.Default,
 78              AttestationPreference = AttestationConveyancePreference.None,
 79          });
 80  
 81          _store.StoreChallenge(username, options.Challenge);
 82          return ServiceResult<string>.Success(options.ToJson());
 83      }
 84  
 85      public async Task<ServiceResult<string>> CompleteRegistrationAsync(
 86          string username,
 87          string attestationResponseJson,
 88          CancellationToken ct = default)
 89      {
 90          var user = await _userManager.FindByNameAsync(username);
 91          if (user is null)
 92              return Err(WebAuthnErrorCode.UserNotFound, $"User '{username}' not found.");
 93  
 94          var challenge = _store.ConsumeChallenge(username);
 95          if (challenge is null)
 96              return Err(WebAuthnErrorCode.ChallengeMissing,
 97                  "No pending registration challenge. Restart registration.");
 98  
 99          AuthenticatorAttestationRawResponse rawResponse;
100          try
101          {
102              rawResponse = JsonSerializer.Deserialize<AuthenticatorAttestationRawResponse>(attestationResponseJson)
103                  ?? throw new JsonException("Null response");
104          }
105          catch (JsonException ex)
106          {
107              return Err(WebAuthnErrorCode.InvalidRequest, $"Malformed attestation response: {ex.Message}");
108          }
109  
110          var options = CredentialCreateOptions.Create(
111              _fido2Config,
112              challenge,
113              new Fido2User
114              {
115                  Id = Encoding.UTF8.GetBytes(user.Id),
116                  Name = username,
117                  DisplayName = username,
118              },
119              AuthenticatorSelection.Default,
120              AttestationConveyancePreference.None,
121              excludeCredentials: [],
122              extensions: null,
123              pubKeyCredParams: PubKeyCredParam.Defaults);
124  
125          try
126          {
127              var credential = await _fido2.MakeNewCredentialAsync(
128                  new MakeNewCredentialParams
129                  {
130                      AttestationResponse = rawResponse,
131                      OriginalOptions = options,
132                      IsCredentialIdUniqueToUserCallback = IsCredentialIdUniqueToUserAsync,
133                  }, ct);
134  
135              var storedCredential = new WebAuthnCredential
136              {
137                  Id = Base64UrlEncode(credential.Id),
138                  UserId = user.Id,
139                  PublicKey = credential.PublicKey,
140                  SignatureCounter = credential.SignCount,
141                  AaGuid = credential.AaGuid,
142                  Transports = credential.Transports?.Select(t => t.ToString()).ToList() ?? [],
143                  RegisteredAt = DateTimeOffset.UtcNow,
144              };
145  
146              _store.UpsertCredential(storedCredential);
147              return ServiceResult<string>.Success(user.Id);
148          }
149          catch (Fido2VerificationException ex)
150          {
151              return Err(WebAuthnErrorCode.AttestationFailed, ex.Message);
152          }
153      }
154  
155      // ── Authentication ───────────────────────────────────────────────────────
156  
157      public async Task<ServiceResult<string>> BeginLoginAsync(string username, CancellationToken ct = default)
158      {
159          var user = await _userManager.FindByNameAsync(username);
160          if (user is null)
161              return Err(WebAuthnErrorCode.UserNotFound, $"User '{username}' not found.");
162  
163          var credentials = _store.GetCredentialsByUserId(user.Id)
164              .Select(c => new PublicKeyCredentialDescriptor(Base64UrlDecode(c.Id)))
165              .ToList();
166  
167          if (credentials.Count == 0)
168              return Err(WebAuthnErrorCode.CredentialNotFound,
169                  $"No WebAuthn credentials registered for '{username}'.");
170  
171          var options = _fido2.GetAssertionOptions(new GetAssertionOptionsParams
172          {
173              AllowedCredentials = credentials,
174              UserVerification = UserVerificationRequirement.Preferred,
175          });
176  
177          _store.StoreChallenge(username, options.Challenge);
178          return ServiceResult<string>.Success(options.ToJson());
179      }
180  
181      public async Task<ServiceResult<string>> CompleteLoginAsync(
182          string username,
183          string assertionResponseJson,
184          CancellationToken ct = default)
185      {
186          var user = await _userManager.FindByNameAsync(username);
187          if (user is null)
188              return Err(WebAuthnErrorCode.UserNotFound, $"User '{username}' not found.");
189  
190          var challenge = _store.ConsumeChallenge(username);
191          if (challenge is null)
192              return Err(WebAuthnErrorCode.ChallengeMissing,
193                  "No pending authentication challenge. Restart login.");
194  
195          AuthenticatorAssertionRawResponse rawResponse;
196          try
197          {
198              rawResponse = JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(assertionResponseJson)
199                  ?? throw new JsonException("Null response");
200          }
201          catch (JsonException ex)
202          {
203              return Err(WebAuthnErrorCode.InvalidRequest, $"Malformed assertion response: {ex.Message}");
204          }
205  
206          var credentialId = Base64UrlEncode(rawResponse.RawId);
207          var storedCredential = _store.GetCredentialById(credentialId);
208          if (storedCredential is null || storedCredential.UserId != user.Id)
209              return Err(WebAuthnErrorCode.CredentialNotFound,
210                  "Credential not registered or belongs to a different user.");
211  
212          var assertionOptions = AssertionOptions.Create(
213              _fido2Config,
214              challenge,
215              allowedCredentials: [],
216              userVerification: UserVerificationRequirement.Preferred,
217              extensions: null);
218  
219          try
220          {
221              var result = await _fido2.MakeAssertionAsync(
222                  new MakeAssertionParams
223                  {
224                      AssertionResponse = rawResponse,
225                      OriginalOptions = assertionOptions,
226                      StoredPublicKey = storedCredential.PublicKey,
227                      StoredSignatureCounter = storedCredential.SignatureCounter,
228                      IsUserHandleOwnerOfCredentialIdCallback = IsUserHandleOwnerOfCredentialIdAsync,
229                  }, ct);
230  
231              // Verify signature counter increased (replay / authenticator clone protection)
232              if (storedCredential.SignatureCounter > 0 && result.SignCount <= storedCredential.SignatureCounter)
233                  return Err(WebAuthnErrorCode.CounterRegression,
234                      $"Signature counter did not increase (stored={storedCredential.SignatureCounter}, received={result.SignCount}). " +
235                      "The authenticator may be cloned.");
236  
237              // Update signature counter and last-used timestamp
238              storedCredential.SignatureCounter = result.SignCount;
239              storedCredential.LastUsedAt = DateTimeOffset.UtcNow;
240              _store.UpsertCredential(storedCredential);
241  
242              return ServiceResult<string>.Success(user.Id);
243          }
244          catch (Fido2VerificationException ex)
245          {
246              return Err(WebAuthnErrorCode.AssertionFailed, ex.Message);
247          }
248      }
249  
250      // ── Discoverable (usernameless) Authentication ────────────────────────────
251  
252      public Task<ServiceResult<(string SessionId, string OptionsJson)>> BeginDiscoverableLoginAsync(
253          CancellationToken ct = default)
254      {
255          var sessionId = Guid.NewGuid().ToString("N");
256  
257          var options = _fido2.GetAssertionOptions(new GetAssertionOptionsParams
258          {
259              AllowedCredentials = [],   // empty -> browser discovers resident credentials
260              UserVerification = UserVerificationRequirement.Preferred,
261          });
262  
263          _store.StoreChallenge($"discoverable:{sessionId}", options.Challenge);
264          return Task.FromResult(
265              ServiceResult<(string, string)>.Success((sessionId, options.ToJson())));
266      }
267  
268      public async Task<ServiceResult<string>> CompleteDiscoverableLoginAsync(
269          string sessionId,
270          string assertionResponseJson,
271          CancellationToken ct = default)
272      {
273          if (string.IsNullOrWhiteSpace(sessionId))
274              return Err(WebAuthnErrorCode.InvalidRequest, "Session ID is required.");
275  
276          var challenge = _store.ConsumeChallenge($"discoverable:{sessionId}");
277          if (challenge is null)
278              return Err(WebAuthnErrorCode.ChallengeMissing,
279                  "No pending authentication challenge. Restart login.");
280  
281          AuthenticatorAssertionRawResponse rawResponse;
282          try
283          {
284              rawResponse = JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(assertionResponseJson)
285                  ?? throw new JsonException("Null response");
286          }
287          catch (JsonException ex)
288          {
289              return Err(WebAuthnErrorCode.InvalidRequest, $"Malformed assertion response: {ex.Message}");
290          }
291  
292          var credentialId = Base64UrlEncode(rawResponse.RawId);
293          var storedCredential = _store.GetCredentialById(credentialId);
294          if (storedCredential is null)
295              return Err(WebAuthnErrorCode.CredentialNotFound, "Credential not found.");
296  
297          var user = await _userManager.FindByIdAsync(storedCredential.UserId);
298          if (user is null)
299              return Err(WebAuthnErrorCode.UserNotFound,
300                  $"User account for credential no longer exists.");
301  
302          var assertionOptions = AssertionOptions.Create(
303              _fido2Config,
304              challenge,
305              allowedCredentials: [],
306              userVerification: UserVerificationRequirement.Preferred,
307              extensions: null);
308  
309          try
310          {
311              var result = await _fido2.MakeAssertionAsync(
312                  new MakeAssertionParams
313                  {
314                      AssertionResponse = rawResponse,
315                      OriginalOptions = assertionOptions,
316                      StoredPublicKey = storedCredential.PublicKey,
317                      StoredSignatureCounter = storedCredential.SignatureCounter,
318                      IsUserHandleOwnerOfCredentialIdCallback = IsUserHandleOwnerOfCredentialIdAsync,
319                  }, ct);
320  
321              if (storedCredential.SignatureCounter > 0 && result.SignCount <= storedCredential.SignatureCounter)
322                  return Err(WebAuthnErrorCode.CounterRegression,
323                      $"Signature counter did not increase (stored={storedCredential.SignatureCounter}, received={result.SignCount}). " +
324                      "The authenticator may be cloned.");
325  
326              storedCredential.SignatureCounter = result.SignCount;
327              storedCredential.LastUsedAt = DateTimeOffset.UtcNow;
328              _store.UpsertCredential(storedCredential);
329  
330              return ServiceResult<string>.Success(storedCredential.UserId);
331          }
332          catch (Fido2VerificationException ex)
333          {
334              return Err(WebAuthnErrorCode.AssertionFailed, ex.Message);
335          }
336      }
337  
338      // ── Delegates ─────────────────────────────────────────────────────────────
339  
340      private Task<bool> IsCredentialIdUniqueToUserAsync(IsCredentialIdUniqueToUserParams args, CancellationToken ct)
341      {
342          var credId = Base64UrlEncode(args.CredentialId.ToArray());
343          var existing = _store.GetCredentialById(credId);
344          return Task.FromResult(existing is null);
345      }
346  
347      private Task<bool> IsUserHandleOwnerOfCredentialIdAsync(IsUserHandleOwnerOfCredentialIdParams args, CancellationToken ct)
348      {
349          var userId = Encoding.UTF8.GetString(args.UserHandle.ToArray());
350          var credId = Base64UrlEncode(args.CredentialId.ToArray());
351          var credential = _store.GetCredentialById(credId);
352          return Task.FromResult(credential?.UserId == userId);
353      }
354  
355      // ── Helpers ───────────────────────────────────────────────────────────────
356  
357      /// <summary>Builds a typed error result embedding the <see cref="WebAuthnErrorCode"/> in the message.</summary>
358      private static ServiceResult<string> Err(WebAuthnErrorCode code, string message) =>
359          ServiceResult<string>.InvalidState($"{code}: {message}");
360  
361      private static string Base64UrlEncode(byte[] data) =>
362          Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
363  
364      private static byte[] Base64UrlDecode(string value)
365      {
366          value = value.Replace('-', '+').Replace('_', '/');
367          switch (value.Length % 4)
368          {
369              case 2: value += "=="; break;
370              case 3: value += "="; break;
371          }
372          return Convert.FromBase64String(value);
373      }
374  }