Skip to content

Commit 09f05c4

Browse files
committed
feat: Implement device name parsing from User-Agent in passkey registration
1 parent edc60fd commit 09f05c4

3 files changed

Lines changed: 161 additions & 0 deletions

File tree

src/BookStore.ApiService/Endpoints/PasskeyEndpoints.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ public static IEndpointRouteBuilder MapPasskeyEndpoints(this IEndpointRouteBuild
114114
return Result.Failure(Error.Validation(ErrorCodes.Passkey.AttestationFailed, $"Attestation failed: {attestation.Failure?.Message}")).ToProblemDetails();
115115
}
116116

117+
// Capture Device Name from User-Agent
118+
var clientUserAgent = context.Request.Headers.UserAgent.ToString();
119+
if (attestation.Passkey != null)
120+
{
121+
attestation.Passkey.Name = BookStore.ApiService.Infrastructure.DeviceNameParser.Parse(clientUserAgent);
122+
}
123+
117124
if (userStore is IUserPasskeyStore<ApplicationUser> passkeyStore)
118125
{
119126
await passkeyStore.AddOrUpdatePasskeyAsync(user, attestation.Passkey!, cancellationToken);
@@ -165,6 +172,13 @@ public static IEndpointRouteBuilder MapPasskeyEndpoints(this IEndpointRouteBuild
165172
return Result.Failure(Error.Validation(ErrorCodes.Passkey.AttestationFailed, $"Attestation failed: {attestationNew.Failure?.Message}")).ToProblemDetails();
166173
}
167174

175+
// Capture Device Name from User-Agent
176+
var registrationUserAgent = context.Request.Headers.UserAgent.ToString();
177+
if (attestationNew.Passkey != null)
178+
{
179+
attestationNew.Passkey.Name = BookStore.ApiService.Infrastructure.DeviceNameParser.Parse(registrationUserAgent);
180+
}
181+
168182
// Use the user ID from the request (sent by client from options response)
169183
// This is critical - the user ID must match what was in the passkey creation options
170184
var userIdSource = "none";
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
namespace BookStore.ApiService.Infrastructure;
2+
3+
public static class DeviceNameParser
4+
{
5+
/// <summary>
6+
/// Parses the User-Agent string to extract a human-readable device/browser name.
7+
/// </summary>
8+
public static string Parse(string userAgent)
9+
{
10+
if (string.IsNullOrEmpty(userAgent))
11+
{
12+
return "Unknown Device";
13+
}
14+
15+
// Simple heuristics for common browsers/OS
16+
// Order matters: check more specific first
17+
18+
var ua = userAgent;
19+
20+
// Detect OS
21+
var os = "Unknown OS";
22+
if (ua.Contains("Windows"))
23+
{
24+
os = "Windows";
25+
}
26+
else if (ua.Contains("iPhone") || ua.Contains("iPad") || ua.Contains("iPod"))
27+
{
28+
os = "iOS";
29+
}
30+
else if (ua.Contains("Macintosh") || ua.Contains("Mac OS X"))
31+
{
32+
os = "macOS";
33+
}
34+
else if (ua.Contains("Android"))
35+
{
36+
os = "Android";
37+
}
38+
else if (ua.Contains("Linux"))
39+
{
40+
os = "Linux";
41+
}
42+
43+
// Detect Browser
44+
var browser = "Unknown Browser";
45+
if (ua.Contains("Edg/"))
46+
{
47+
browser = "Edge";
48+
}
49+
else if (ua.Contains("Chrome") && !ua.Contains("Edg/"))
50+
{
51+
browser = "Chrome"; // Chrome user agent also contains Safari
52+
}
53+
else if (ua.Contains("Firefox"))
54+
{
55+
browser = "Firefox";
56+
}
57+
else if (ua.Contains("Safari") && !ua.Contains("Chrome"))
58+
{
59+
browser = "Safari";
60+
}
61+
62+
// Passkey managers/native apps often don't send standard browser UAs, or send specific ones.
63+
// If it looks like a browser, combine them.
64+
if (browser != "Unknown Browser" && os != "Unknown OS")
65+
{
66+
return $"{browser} on {os}";
67+
}
68+
else if (browser != "Unknown Browser")
69+
{
70+
return browser;
71+
}
72+
else if (os != "Unknown OS")
73+
{
74+
return os;
75+
}
76+
77+
// Fallback: truncate UA if too long
78+
return ua.Length > 30 ? ua[..27] + "..." : ua;
79+
}
80+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
using BookStore.ApiService.Infrastructure;
2+
3+
namespace BookStore.ApiService.UnitTests.Infrastructure;
4+
5+
public class DeviceNameParserTests
6+
{
7+
[Test]
8+
public async Task Parse_ChromeOnMac_ReturnsExpectedDeviceName()
9+
{
10+
var userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
11+
var expected = "Chrome on macOS";
12+
var result = DeviceNameParser.Parse(userAgent);
13+
await Assert.That(result).IsEqualTo(expected);
14+
}
15+
16+
[Test]
17+
public async Task Parse_ChromeOnAndroid_ReturnsExpectedDeviceName()
18+
{
19+
var userAgent = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36";
20+
var expected = "Chrome on Android";
21+
var result = DeviceNameParser.Parse(userAgent);
22+
await Assert.That(result).IsEqualTo(expected);
23+
}
24+
25+
[Test]
26+
public async Task Parse_SafariOnIOS_ReturnsExpectedDeviceName()
27+
{
28+
var userAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1";
29+
var expected = "Safari on iOS";
30+
var result = DeviceNameParser.Parse(userAgent);
31+
await Assert.That(result).IsEqualTo(expected);
32+
}
33+
34+
[Test]
35+
public async Task Parse_FirefoxOnWindows_ReturnsExpectedDeviceName()
36+
{
37+
var userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0";
38+
var expected = "Firefox on Windows";
39+
var result = DeviceNameParser.Parse(userAgent);
40+
await Assert.That(result).IsEqualTo(expected);
41+
}
42+
43+
[Test]
44+
public async Task Parse_EdgeOnWindows_ReturnsExpectedDeviceName()
45+
{
46+
var userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0";
47+
var expected = "Edge on Windows";
48+
var result = DeviceNameParser.Parse(userAgent);
49+
await Assert.That(result).IsEqualTo(expected);
50+
}
51+
52+
[Test]
53+
public async Task Parse_WithEmptyString_ReturnsUnknownDevice()
54+
{
55+
var result = DeviceNameParser.Parse("");
56+
await Assert.That(result).IsEqualTo("Unknown Device");
57+
}
58+
59+
[Test]
60+
public async Task Parse_WithNonBrowserUserAgent_ReturnsTruncatedString()
61+
{
62+
var result = DeviceNameParser.Parse("MyCustomApp/1.0 (Some long detailed info)");
63+
// Logic truncates > 30 chars
64+
await Assert.That(result).StartsWith("MyCustomApp/1.0 (Some long ");
65+
await Assert.That(result).EndsWith("...");
66+
}
67+
}

0 commit comments

Comments
 (0)