Skip to content

Commit 5daa9ac

Browse files
committed
UAuthUserAgentParser and Complete DeviceContext Implementation on WASM
1 parent bfe6c13 commit 5daa9ac

File tree

27 files changed

+380
-134
lines changed

27 files changed

+380
-134
lines changed

samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,44 @@
1-
@inherits LayoutComponentBase
1+
@inherits UAuthHubLayoutComponentBase
22
@inject IUAuthClient UAuthClient
33
@inject ISnackbar Snackbar
44
@inject NavigationManager Nav
55

6+
@if (!IsHubAuthorized)
7+
{
8+
<MudAppBar Class="uauth-blur" Color="Color.Transparent" Dense="true" Elevation="0">
9+
<UAuthLogo />
10+
<MudText Class="ml-2 cursor-pointer" Style="user-select: none" @onclick="@(() => Nav.NavigateTo("/home", true))"><b>UltimateAuth</b></MudText>
11+
<MudDivider Class="ml-3 mr-1" Vertical="true" />
12+
<MudText Class="ml-2" Style="line-height: 14px" Typo="Typo.subtitle2">UAuthHub Sample</MudText>
13+
14+
<MudSpacer />
15+
16+
<MudIconButton Icon="@(DarkModeManager.IsDarkMode? Icons.Material.Filled.LightMode : Icons.Material.Filled.DarkMode)" OnClick="@(() => DarkModeManager.ToggleAsync())" />
17+
</MudAppBar>
18+
19+
<MudPage Class="d-flex align-center justify-center" FullScreen="FullScreen.FullWithoutAppbar" Column="1" Row="1">
20+
<MudPaper Class="pa-8" Elevation="4" Style="max-width: 520px; width: 100%;">
21+
<MudStack Spacing="3" AlignItems="AlignItems.Center">
22+
<UAuthLogo Size="72" />
23+
24+
<MudText Typo="Typo.h5"><b>Access Denied</b></MudText>
25+
26+
<MudText Typo="Typo.body2" Align="Align.Center">
27+
This page cannot be accessed directly.
28+
UAuthHub login flows can only be initiated by an authorized client application.
29+
</MudText>
30+
31+
<MudDivider Class="my-2" />
32+
33+
<MudText Typo="Typo.caption" Color="Color.Secondary">
34+
UltimateAuth protects this resource based on your session and permissions.
35+
</MudText>
36+
</MudStack>
37+
</MudPaper>
38+
</MudPage>
39+
return;
40+
}
41+
642
<MudLayout>
743
<MudAppBar Class="uauth-blur" Color="Color.Transparent" Dense="true" Elevation="0">
844
<UAuthLogo />

samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -24,31 +24,6 @@
2424
@inject IDialogService DialogService
2525
@inject IOptions<UAuthClientOptions> Options
2626

27-
@if (!IsHubAuthorized)
28-
{
29-
<MudPage Class="d-flex align-center justify-center" FullScreen="FullScreen.FullWithoutAppbar" Column="1" Row="1">
30-
<MudPaper Class="pa-8" Elevation="4" Style="max-width: 520px; width: 100%;">
31-
<MudStack Spacing="3" AlignItems="AlignItems.Center">
32-
<UAuthLogo Size="72" />
33-
34-
<MudText Typo="Typo.h5"><b>Access Denied</b></MudText>
35-
36-
<MudText Typo="Typo.body2" Align="Align.Center">
37-
This page cannot be accessed directly.
38-
UAuthHub login flows can only be initiated by an authorized client application.
39-
</MudText>
40-
41-
<MudDivider Class="my-2" />
42-
43-
<MudText Typo="Typo.caption" Color="Color.Secondary">
44-
UltimateAuth protects this resource based on your session and permissions.
45-
</MudText>
46-
</MudStack>
47-
</MudPaper>
48-
</MudPage>
49-
return;
50-
}
51-
5227
<MudPage FullScreen="FullScreen.FullWithoutAppbar" Column="1" Row="1">
5328
<MudContainer Class="d-flex align-center justify-center" MaxWidth="MaxWidth.Medium" Gutters="false">
5429
<MudGrid Spacing="0">
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using CodeBeam.UltimateAuth.Core.Domain;
2+
3+
namespace CodeBeam.UltimateAuth.Core.Abstractions;
4+
5+
public interface IUserAgentParser
6+
{
7+
UserAgentInfo Parse(string? userAgent);
8+
}

src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeCommand.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using CodeBeam.UltimateAuth.Core.MultiTenancy;
1+
using CodeBeam.UltimateAuth.Core.Domain;
2+
using CodeBeam.UltimateAuth.Core.MultiTenancy;
23
using CodeBeam.UltimateAuth.Core.Options;
34

45
namespace CodeBeam.UltimateAuth.Core.Contracts;
@@ -7,7 +8,7 @@ public sealed record PkceAuthorizeCommand
78
{
89
public string CodeChallenge { get; init; } = default!;
910
public string ChallengeMethod { get; init; } = "S256";
10-
public string? DeviceId { get; init; }
11+
public required DeviceContext Device { get; init; }
1112
public string? RedirectUri { get; init; }
1213

1314
public UAuthClientProfile ClientProfile { get; init; }
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace CodeBeam.UltimateAuth.Core.Domain;
2+
3+
public sealed class UserAgentInfo
4+
{
5+
public string? DeviceType { get; init; }
6+
public string? Platform { get; init; }
7+
public string? OperatingSystem { get; init; }
8+
public string? Browser { get; init; }
9+
}

src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public sealed class HubFlowArtifact : AuthArtifact
1010

1111
public UAuthClientProfile ClientProfile { get; }
1212
public TenantKey Tenant { get; }
13+
public DeviceContext Device { get; }
1314
public string? ReturnUrl { get; }
1415

1516
public HubFlowPayload Payload { get; }
@@ -21,6 +22,7 @@ public HubFlowArtifact(
2122
HubFlowType flowType,
2223
UAuthClientProfile clientProfile,
2324
TenantKey tenant,
25+
DeviceContext device,
2426
string? returnUrl,
2527
HubFlowPayload payload,
2628
DateTimeOffset expiresAt)
@@ -30,6 +32,7 @@ public HubFlowArtifact(
3032
FlowType = flowType;
3133
ClientProfile = clientProfile;
3234
Tenant = tenant;
35+
Device = device;
3336
ReturnUrl = returnUrl;
3437
Payload = payload;
3538
}

src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ private static IServiceCollection AddUltimateAuthInternal(this IServiceCollectio
7474

7575
services.AddSingleton<IUserIdConverterResolver, UAuthUserIdConverterResolver>();
7676
services.TryAddSingleton<IUAuthProductInfoProvider, UAuthProductInfoProvider>();
77+
services.TryAddSingleton<IUserAgentParser, UAuthUserAgentParser>();
7778

7879
return services;
7980
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
using CodeBeam.UltimateAuth.Core.Abstractions;
2+
using CodeBeam.UltimateAuth.Core.Domain;
3+
4+
namespace CodeBeam.UltimateAuth.Core.Infrastructure;
5+
6+
internal sealed class UAuthUserAgentParser : IUserAgentParser
7+
{
8+
public UserAgentInfo Parse(string? userAgent)
9+
{
10+
if (string.IsNullOrWhiteSpace(userAgent))
11+
return new UserAgentInfo();
12+
13+
var ua = userAgent.ToLowerInvariant();
14+
15+
return new UserAgentInfo
16+
{
17+
DeviceType = ResolveDeviceType(ua),
18+
Platform = ResolvePlatform(ua),
19+
OperatingSystem = ResolveOperatingSystem(ua),
20+
Browser = ResolveBrowser(ua)
21+
};
22+
}
23+
24+
private static string ResolveDeviceType(string ua)
25+
{
26+
if (ua.Contains("ipad") || ua.Contains("tablet"))
27+
return "tablet";
28+
29+
if (ua.Contains("mobi") || ua.Contains("iphone") || ua.Contains("android"))
30+
return "mobile";
31+
32+
return "desktop";
33+
}
34+
35+
private static string ResolvePlatform(string ua)
36+
{
37+
if (ua.Contains("ipad") || ua.Contains("tablet"))
38+
return "tablet";
39+
40+
if (ua.Contains("mobi") || ua.Contains("iphone") || ua.Contains("android"))
41+
return "mobile";
42+
43+
return "desktop";
44+
}
45+
46+
private static string ResolveOperatingSystem(string ua)
47+
{
48+
if (ua.Contains("android"))
49+
return "android";
50+
51+
if (ua.Contains("iphone") || ua.Contains("ipad"))
52+
return "ios";
53+
54+
if (ua.Contains("windows"))
55+
return "windows";
56+
57+
if (ua.Contains("mac"))
58+
return "macos";
59+
60+
if (ua.Contains("linux"))
61+
return "linux";
62+
63+
return "unknown";
64+
}
65+
66+
private static string ResolveBrowser(string ua)
67+
{
68+
if (ua.Contains("edg/"))
69+
return "edge";
70+
71+
if (ua.Contains("opr/") || ua.Contains("opera"))
72+
return "opera";
73+
74+
if (ua.Contains("chrome") && !ua.Contains("chromium"))
75+
return "chrome";
76+
77+
if (ua.Contains("safari") && !ua.Contains("chrome"))
78+
return "safari";
79+
80+
if (ua.Contains("firefox"))
81+
return "firefox";
82+
83+
return "unknown";
84+
}
85+
}

src/CodeBeam.UltimateAuth.Server/Contracts/Hub/HubBeginRequest.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using CodeBeam.UltimateAuth.Core.MultiTenancy;
1+
using CodeBeam.UltimateAuth.Core.Domain;
2+
using CodeBeam.UltimateAuth.Core.MultiTenancy;
23
using CodeBeam.UltimateAuth.Core.Options;
34

45
namespace CodeBeam.UltimateAuth.Server.Contracts;
@@ -15,5 +16,5 @@ public sealed record HubBeginRequest
1516

1617
public string? PreviousHubSessionId { get; init; }
1718

18-
public string? DeviceId { get; init; }
19+
public required DeviceContext Device { get; init; }
1920
}

src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
using CodeBeam.UltimateAuth.Core.Abstractions;
22
using CodeBeam.UltimateAuth.Core.Contracts;
3+
using CodeBeam.UltimateAuth.Core.Defaults;
34
using CodeBeam.UltimateAuth.Core.Domain;
45
using CodeBeam.UltimateAuth.Server.Abstractions;
56
using CodeBeam.UltimateAuth.Server.Auth;
67
using CodeBeam.UltimateAuth.Server.Extensions;
78
using CodeBeam.UltimateAuth.Server.Flows;
89
using CodeBeam.UltimateAuth.Server.Infrastructure;
9-
using CodeBeam.UltimateAuth.Server.Options;
1010
using CodeBeam.UltimateAuth.Server.Services;
1111
using CodeBeam.UltimateAuth.Server.Stores;
1212
using Microsoft.AspNetCore.Http;
13-
using Microsoft.Extensions.Options;
13+
using Microsoft.AspNetCore.WebUtilities;
14+
using System.Text;
15+
using System.Text.Json;
1416

1517
namespace CodeBeam.UltimateAuth.Server.Endpoints;
1618

@@ -61,7 +63,7 @@ public async Task<IResult> AuthorizeAsync(HttpContext ctx)
6163
{
6264
CodeChallenge = request.CodeChallenge,
6365
ChallengeMethod = request.ChallengeMethod,
64-
DeviceId = request.DeviceId,
66+
Device = request.Device,
6567
RedirectUri = request.RedirectUri,
6668
ClientProfile = auth.ClientProfile,
6769
Tenant = auth.Tenant
@@ -108,7 +110,7 @@ public async Task<IResult> TryCompleteAsync(HttpContext ctx)
108110
clientProfile: authContext.ClientProfile,
109111
tenant: authContext.Tenant,
110112
redirectUri: null,
111-
deviceId: artifact.Context.DeviceId),
113+
device: artifact.Context.Device),
112114
_clock.UtcNow);
113115

114116
if (!validation.Success)
@@ -130,7 +132,7 @@ public async Task<IResult> TryCompleteAsync(HttpContext ctx)
130132
var execution = new AuthExecutionContext
131133
{
132134
EffectiveClientProfile = artifact.Context.ClientProfile,
133-
Device = DeviceContext.Create(DeviceId.Create(artifact.Context.DeviceId))
135+
Device = artifact.Context.Device
134136
};
135137

136138
var preview = await _internalFlowService.LoginAsync(authContext, execution, loginRequest,
@@ -204,19 +206,41 @@ public async Task<IResult> CompleteAsync(HttpContext ctx)
204206

205207
if (ctx.Request.HasFormContentType)
206208
{
207-
var form = await ctx.Request.ReadFormAsync(ctx.RequestAborted);
209+
var form = await ctx.GetCachedFormAsync();
208210

209211
var codeChallenge = form["code_challenge"].ToString();
210212
var challengeMethod = form["challenge_method"].ToString();
211213
var redirectUri = form["redirect_uri"].ToString();
212-
var deviceId = form["device_id"].ToString();
214+
215+
var deviceRaw = form["device"].FirstOrDefault();
216+
DeviceContext? device = null;
217+
218+
if (!string.IsNullOrWhiteSpace(deviceRaw))
219+
{
220+
try
221+
{
222+
var bytes = WebEncoders.Base64UrlDecode(deviceRaw);
223+
var json = Encoding.UTF8.GetString(bytes);
224+
device = JsonSerializer.Deserialize<DeviceContext>(json);
225+
}
226+
catch
227+
{
228+
device = null;
229+
}
230+
}
231+
232+
if (device == null)
233+
{
234+
var info = await ctx.GetDeviceAsync();
235+
device = DeviceContext.Create(info.DeviceId, info.DeviceType, info.Platform, info.OperatingSystem, info.Browser, info.IpAddress);
236+
}
213237

214238
return new PkceAuthorizeRequest
215239
{
216240
CodeChallenge = codeChallenge,
217241
ChallengeMethod = challengeMethod,
218242
RedirectUri = string.IsNullOrWhiteSpace(redirectUri) ? null : redirectUri,
219-
DeviceId = deviceId
243+
Device = device
220244
};
221245
}
222246

@@ -268,15 +292,15 @@ private async Task<IResult> RedirectToLoginWithErrorAsync(HttpContext ctx, AuthF
268292
hub.SetError(HubErrorCode.InvalidCredentials);
269293
await _authStore.StoreAsync(key, hub);
270294

271-
return Results.Redirect($"{basePath}?hub={Uri.EscapeDataString(hubKey)}");
295+
return Results.Redirect($"{basePath}?{UAuthConstants.Query.Hub}={Uri.EscapeDataString(hubKey)}");
272296
}
273297
}
274298
return Results.Redirect(basePath);
275299
}
276300

277301
private async Task<string?> ResolveHubKeyAsync(HttpContext ctx)
278302
{
279-
if (ctx.Request.Query.TryGetValue("hub", out var q) && !string.IsNullOrWhiteSpace(q))
303+
if (ctx.Request.Query.TryGetValue(UAuthConstants.Query.Hub, out var q) && !string.IsNullOrWhiteSpace(q))
280304
return q.ToString();
281305

282306
if (ctx.Request.HasFormContentType)

0 commit comments

Comments
 (0)