Skip to content

Commit bd27e1e

Browse files
committed
adds OAuth support
1 parent a9ae1ea commit bd27e1e

7 files changed

Lines changed: 347 additions & 30 deletions

File tree

CodeWorks.Auth.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<Nullable>enable</Nullable>
77

88
<PackageId>CodeWorks.Auth</PackageId>
9-
<Version>0.0.2</Version>
9+
<Version>0.0.3</Version>
1010
<Authors>CodeWorks</Authors>
1111
<Company>CodeWorks</Company>
1212
<Description>Flexible, pluggable authentication module for .NET APIs with JWT, email login, and MFA support.</Description>

Interfaces/IAccountIdentity.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
using CodeWorks.Auth.Models;
2+
using Microsoft.AspNetCore.Identity;
3+
14
namespace CodeWorks.Auth.Interfaces;
25

36
public interface IAccountIdentity
@@ -13,3 +16,20 @@ public interface IAccountIdentity
1316
List<string> Roles { get; set; }
1417
List<string> Permissions { get; set; }
1518
}
19+
20+
public interface IOAuthUser : IAccountIdentity
21+
{
22+
string? Provider { get; set; } // "google", "facebook", "local"
23+
string? ProviderId { get; set; } // User ID from OAuth provider
24+
string? ProfilePictureUrl { get; set; }
25+
}
26+
27+
public interface IOAuthService<TUser> where TUser : IOAuthUser
28+
{
29+
Task<AuthResult<TUser>> HandleOAuthCallbackAsync(
30+
ExternalLoginInfo loginInfo);
31+
32+
Task<string> GenerateOAuthStateAsync(string provider);
33+
34+
Task<bool> ValidateOAuthStateAsync(string state);
35+
}

Interfaces/IAccountIdentityStore.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ public interface IAccountIdentityStore<TIdentity> where TIdentity : IAccountIden
77
Task SaveAsync(TIdentity user);
88
Task<TIdentity> FindByIdAsync(string id);
99

10+
Task<TIdentity> FindByProviderAsync(string provider, string providerId);
11+
12+
13+
1014
async public Task<TIdentity> MarkEmailVerifiedAsync(TIdentity user)
1115
{
1216
if (user == null) throw new ArgumentNullException(nameof(user));

Models/AuthResult.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
using CodeWorks.Auth.Interfaces;
22

3-
public class AuthResult
3+
namespace CodeWorks.Auth.Models;
4+
5+
public class AuthResult<UserT>
46
{
57
public bool IsSuccessful { get; init; }
6-
public IAccountIdentity? User { get; init; }
8+
public UserT? User { get; init; }
79
public string? Token { get; init; }
810
public string? Error { get; init; }
911

10-
public static AuthResult Success(IAccountIdentity user, string token) => new() { IsSuccessful = true, Token = token, User = user };
11-
public static AuthResult Failure(string message) => new() { IsSuccessful = false, Error = message };
12+
public static AuthResult<UserT> Success(UserT user, string token) => new() { IsSuccessful = true, Token = token, User = user };
13+
public static AuthResult<UserT> Failure(string message) => new() { IsSuccessful = false, Error = message };
1214
}

Services/AuthService.cs

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
using CodeWorks.Auth.Extensions;
22
using CodeWorks.Auth.Interfaces;
3+
using CodeWorks.Auth.Models;
34
using CodeWorks.Auth.Security;
45
using Microsoft.AspNetCore.Identity;
56

67
namespace CodeWorks.Auth.Services;
78

89
public interface IAuthService<TIdentity> where TIdentity : IAccountIdentity
910
{
10-
Task<AuthResult> LoginAsync(string email, string password);
11-
Task<AuthResult> RegisterAsync(TIdentity user, string password);
12-
Task<AuthResult> ResetPasswordAsync(string email, string newPassword);
13-
Task<AuthResult> RefreshAuthToken(string token, int refreshExtensionInHours = 1);
14-
AuthResult GenerateAuthToken(IAccountIdentity user);
11+
Task<AuthResult<TIdentity>> LoginAsync(string email, string password);
12+
Task<AuthResult<TIdentity>> RegisterAsync(TIdentity user, string password);
13+
Task<AuthResult<TIdentity>> ResetPasswordAsync(string email, string newPassword);
14+
Task<AuthResult<TIdentity>> RefreshAuthToken(string token, int refreshExtensionInHours = 1);
15+
AuthResult<TIdentity> GenerateAuthToken(TIdentity user);
1516

1617
}
1718

@@ -20,59 +21,59 @@ public class AuthService<TIdentity>(IAccountIdentityStore<TIdentity> store, IJwt
2021
private readonly IAccountIdentityStore<TIdentity> _store = store;
2122
private readonly IJwtService _jwt = jwt;
2223

23-
public async Task<AuthResult> RegisterAsync(TIdentity user, string password)
24+
public async Task<AuthResult<TIdentity>> RegisterAsync(TIdentity user, string password)
2425
{
2526
if (await _store.EmailExistsAsync(user.Email))
26-
return AuthResult.Failure("Email already registered.");
27+
return AuthResult<TIdentity>.Failure("Email already registered.");
2728

28-
user.PasswordHash = PasswordHelper<IAccountIdentity>.HashPassword(user, password);
29+
user.PasswordHash = PasswordHelper<TIdentity>.HashPassword(user, password);
2930
await _store.SaveAsync(user);
30-
return AuthResult.Success(user, _jwt.GenerateToken(user));
31+
return AuthResult<TIdentity>.Success(user, _jwt.GenerateToken(user));
3132
}
3233

33-
public async Task<AuthResult> LoginAsync(string email, string password)
34+
public async Task<AuthResult<TIdentity>> LoginAsync(string email, string password)
3435
{
3536
var user = await _store.FindByEmailAsync(email);
3637
if (user == null)
37-
return AuthResult.Failure("Invalid credentials.");
38+
return AuthResult<TIdentity>.Failure("Invalid credentials.");
3839

39-
var result = PasswordHelper<IAccountIdentity>.VerifyPassword(user, user.PasswordHash, password);
40+
var result = PasswordHelper<TIdentity>.VerifyPassword(user, user.PasswordHash, password);
4041
if (result == PasswordVerificationResult.Failed)
41-
return AuthResult.Failure("Invalid credentials.");
42+
return AuthResult<TIdentity>.Failure("Invalid credentials.");
4243

43-
return AuthResult.Success(user, _jwt.GenerateToken(user));
44+
return AuthResult<TIdentity>.Success(user, _jwt.GenerateToken(user));
4445
}
4546

46-
public async Task<AuthResult> ResetPasswordAsync(string email, string newPassword)
47+
public async Task<AuthResult<TIdentity>> ResetPasswordAsync(string email, string newPassword)
4748
{
4849
var user = await _store.FindByEmailAsync(email);
4950
if (user == null)
50-
return AuthResult.Failure("User not found.");
51+
return AuthResult<TIdentity>.Failure("User not found.");
5152

52-
user.PasswordHash = PasswordHelper<IAccountIdentity>.HashPassword(user, newPassword);
53+
user.PasswordHash = PasswordHelper<TIdentity>.HashPassword(user, newPassword);
5354
await _store.SaveAsync(user);
54-
return AuthResult.Success(user, _jwt.GenerateToken(user));
55+
return AuthResult<TIdentity>.Success(user, _jwt.GenerateToken(user));
5556
}
5657

57-
public AuthResult GenerateAuthToken(IAccountIdentity user)
58+
public AuthResult<TIdentity> GenerateAuthToken(TIdentity user)
5859
{
5960
if (user == null)
60-
return AuthResult.Failure("Invalid credentials.");
61-
return AuthResult.Success(user, _jwt.GenerateToken(user));
61+
return AuthResult<TIdentity>.Failure("Invalid credentials.");
62+
return AuthResult<TIdentity>.Success(user, _jwt.GenerateToken(user));
6263
}
6364

6465

65-
public async Task<AuthResult> RefreshAuthToken(string token, int refreshExtensionInHours = 1)
66+
public async Task<AuthResult<TIdentity>> RefreshAuthToken(string token, int refreshExtensionInHours = 1)
6667
{
6768
var email = _jwt.GetEmailFromToken(token);
6869
var user = await _store.FindByEmailAsync(email);
6970
if (user == null)
70-
return AuthResult.Failure("Invalid credentials.");
71+
return AuthResult<TIdentity>.Failure("Invalid credentials.");
7172

7273
var result = _jwt.RefreshToken(token, user, refreshExtensionInHours);
7374
if (result == null)
74-
return AuthResult.Failure("Invalid token.");
75-
return AuthResult.Success(user, result);
75+
return AuthResult<TIdentity>.Failure("Invalid token.");
76+
return AuthResult<TIdentity>.Success(user, result);
7677
}
7778

7879
}

Services/OAuthService.cs

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
using System.Security.Claims;
2+
using System.Security.Cryptography;
3+
using CodeWorks.Auth.Interfaces;
4+
using CodeWorks.Auth.Models;
5+
using Microsoft.AspNetCore.Identity;
6+
using Microsoft.Extensions.Logging;
7+
8+
namespace CodeWorks.Auth.Services;
9+
10+
public class OAuthService<TUser> : IOAuthService<TUser>
11+
where TUser : class, IOAuthUser, new()
12+
{
13+
private readonly IAccountIdentityStore<TUser> _userStore;
14+
private readonly IAuthService<TUser> _authService;
15+
private readonly ILogger<OAuthService<TUser>> _logger;
16+
17+
public OAuthService(
18+
IAccountIdentityStore<TUser> userStore,
19+
IAuthService<TUser> authService,
20+
ILogger<OAuthService<TUser>> logger)
21+
{
22+
_userStore = userStore;
23+
_authService = authService;
24+
_logger = logger;
25+
}
26+
27+
public async Task<AuthResult<TUser>> HandleOAuthCallbackAsync(
28+
ExternalLoginInfo loginInfo)
29+
{
30+
try
31+
{
32+
var email = loginInfo.Principal.FindFirstValue(ClaimTypes.Email);
33+
var providerId = loginInfo.ProviderKey;
34+
var provider = loginInfo.LoginProvider.ToLower();
35+
36+
if (string.IsNullOrEmpty(email))
37+
{
38+
return AuthResult<TUser>.Failure("Email not provided by OAuth provider");
39+
}
40+
41+
// Check if user exists by provider ID
42+
var existingUser = await _userStore.FindByProviderAsync(provider, providerId);
43+
44+
if (existingUser != null)
45+
{
46+
return GenerateUserToken(existingUser);
47+
}
48+
49+
// Check if user exists by email
50+
existingUser = await _userStore.FindByEmailAsync(email);
51+
52+
if (existingUser != null)
53+
{
54+
// Link OAuth account to existing user
55+
existingUser.Provider = provider;
56+
existingUser.ProviderId = providerId;
57+
existingUser.IsEmailVerified = true; // OAuth providers verify emails
58+
59+
if (loginInfo.Principal.HasClaim(c => c.Type == "picture"))
60+
{
61+
existingUser.ProfilePictureUrl =
62+
loginInfo.Principal.FindFirstValue("picture");
63+
}
64+
65+
await _userStore.SaveAsync(existingUser);
66+
67+
return GenerateUserToken(existingUser);
68+
}
69+
70+
// Create new user
71+
var newUser = new TUser
72+
{
73+
Id = Guid.NewGuid().ToString(),
74+
Email = email,
75+
Provider = provider,
76+
ProviderId = providerId,
77+
IsEmailVerified = true,
78+
Name = loginInfo.Principal.FindFirstValue(ClaimTypes.Name) ?? email.Substring(0, email.IndexOf('@')),
79+
Picture = loginInfo.Principal.FindFirstValue("picture") ?? string.Empty,
80+
ProfilePictureUrl = loginInfo.Principal.FindFirstValue("picture") ?? string.Empty,
81+
Roles = ["user"],
82+
Permissions = ["read"]
83+
};
84+
85+
if (loginInfo.Principal.HasClaim(c => c.Type == "picture"))
86+
{
87+
newUser.ProfilePictureUrl =
88+
loginInfo.Principal.FindFirstValue("picture");
89+
}
90+
91+
await _userStore.SaveAsync(newUser);
92+
93+
return GenerateUserToken(newUser);
94+
}
95+
catch (Exception ex)
96+
{
97+
_logger.LogError(ex, "Error handling OAuth callback");
98+
return AuthResult<TUser>.Failure("OAuth authentication failed");
99+
}
100+
}
101+
102+
103+
private AuthResult<TUser> GenerateUserToken(TUser existingUser)
104+
{
105+
var user = _authService.GenerateAuthToken(existingUser) ?? throw new Exception("Failed to generate auth token");
106+
if (!user.IsSuccessful)
107+
{
108+
return AuthResult<TUser>.Failure("Failed to generate auth token");
109+
}
110+
111+
if (user.User == null)
112+
{
113+
return AuthResult<TUser>.Failure("User not found after token generation");
114+
}
115+
return AuthResult<TUser>.Success(user.User, user.Token!);
116+
}
117+
118+
119+
120+
public async Task<string> GenerateOAuthStateAsync(string provider)
121+
{
122+
// Generate secure state token to prevent CSRF
123+
var state = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
124+
// Store state with expiration (implement in token store)
125+
return state;
126+
}
127+
128+
public async Task<bool> ValidateOAuthStateAsync(string state)
129+
{
130+
// Validate and consume state token
131+
// Implement in token store
132+
return true;
133+
}
134+
}

0 commit comments

Comments
 (0)