✨ Complete oauth / oidc
This commit is contained in:
		| @@ -73,7 +73,7 @@ public class AuthService( | ||||
|         return totalRequiredSteps; | ||||
|     } | ||||
|  | ||||
|     public async Task<Session> CreateSessionAsync(Account.Account account, Instant time) | ||||
|     public async Task<Session> CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null) | ||||
|     { | ||||
|         var challenge = new Challenge | ||||
|         { | ||||
| @@ -82,7 +82,7 @@ public class AuthService( | ||||
|             UserAgent = HttpContext.Request.Headers.UserAgent, | ||||
|             StepRemain = 1, | ||||
|             StepTotal = 1, | ||||
|             Type = ChallengeType.Oidc | ||||
|             Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc | ||||
|         }; | ||||
|  | ||||
|         var session = new Session | ||||
| @@ -90,7 +90,8 @@ public class AuthService( | ||||
|             AccountId = account.Id, | ||||
|             CreatedAt = time, | ||||
|             LastGrantedAt = time, | ||||
|             Challenge = challenge | ||||
|             Challenge = challenge, | ||||
|             AppId = customAppId | ||||
|         }; | ||||
|  | ||||
|         db.AuthChallenges.Add(challenge); | ||||
|   | ||||
| @@ -10,6 +10,7 @@ using System.Text.Json.Serialization; | ||||
| using DysonNetwork.Sphere.Account; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers; | ||||
|  | ||||
| @@ -43,35 +44,67 @@ public class OidcProviderController( | ||||
|                     return BadRequest(new ErrorResponse | ||||
|                         { Error = "invalid_client", ErrorDescription = "Invalid client credentials" }); | ||||
|  | ||||
|                 // Validate the authorization code | ||||
|                 var authCode = await oidcService.ValidateAuthorizationCodeAsync( | ||||
|                     request.Code ?? string.Empty, | ||||
|                     request.ClientId.Value, | ||||
|                     request.RedirectUri, | ||||
|                     request.CodeVerifier | ||||
|                 ); | ||||
|  | ||||
|                 if (authCode == null) | ||||
|                 { | ||||
|                     logger.LogWarning(@"Invalid or expired authorization code: {Code}", request.Code); | ||||
|                     return BadRequest(new ErrorResponse | ||||
|                         { Error = "invalid_grant", ErrorDescription = "Invalid or expired authorization code" }); | ||||
|                 } | ||||
|  | ||||
|                 // Generate tokens | ||||
|                 var tokenResponse = await oidcService.GenerateTokenResponseAsync( | ||||
|                     clientId: request.ClientId.Value, | ||||
|                     scopes: authCode.Scopes, | ||||
|                     authorizationCode: request.Code! | ||||
|                     authorizationCode: request.Code!, | ||||
|                     redirectUri: request.RedirectUri, | ||||
|                     codeVerifier: request.CodeVerifier | ||||
|                 ); | ||||
|  | ||||
|                 return Ok(tokenResponse); | ||||
|             } | ||||
|             case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken): | ||||
|                 return BadRequest(new ErrorResponse | ||||
|                     { Error = "invalid_request", ErrorDescription = "Refresh token is required" }); | ||||
|             case "refresh_token": | ||||
|                 // Handle refresh token request | ||||
|                 // In a real implementation, you would validate the refresh token | ||||
|                 // and issue a new access token | ||||
|                 return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" }); | ||||
|             { | ||||
|                 try | ||||
|                 { | ||||
|                     // Decode the base64 refresh token to get the session ID | ||||
|                     var sessionIdBytes = Convert.FromBase64String(request.RefreshToken); | ||||
|                     var sessionId = new Guid(sessionIdBytes); | ||||
|  | ||||
|                     // Find the session and related data | ||||
|                     var session = await oidcService.FindSessionByIdAsync(sessionId); | ||||
|                     var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|                     if (session?.App is null || session.ExpiredAt < now) | ||||
|                     { | ||||
|                         return BadRequest(new ErrorResponse | ||||
|                         { | ||||
|                             Error = "invalid_grant", | ||||
|                             ErrorDescription = "Invalid or expired refresh token" | ||||
|                         }); | ||||
|                     } | ||||
|  | ||||
|                     // Get the client | ||||
|                     var client = session.App; | ||||
|                     if (client == null) | ||||
|                     { | ||||
|                         return BadRequest(new ErrorResponse | ||||
|                         { | ||||
|                             Error = "invalid_client", | ||||
|                             ErrorDescription = "Client not found" | ||||
|                         }); | ||||
|                     } | ||||
|  | ||||
|                     // Generate new tokens | ||||
|                     var tokenResponse = await oidcService.GenerateTokenResponseAsync( | ||||
|                         clientId: session.AppId!.Value, | ||||
|                         sessionId: session.Id | ||||
|                     ); | ||||
|  | ||||
|                     return Ok(tokenResponse); | ||||
|                 } | ||||
|                 catch (FormatException) | ||||
|                 { | ||||
|                     return BadRequest(new ErrorResponse | ||||
|                     { | ||||
|                         Error = "invalid_grant", | ||||
|                         ErrorDescription = "Invalid refresh token format" | ||||
|                     }); | ||||
|                 } | ||||
|             } | ||||
|             default: | ||||
|                 return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" }); | ||||
|         } | ||||
| @@ -79,7 +112,7 @@ public class OidcProviderController( | ||||
|  | ||||
|     [HttpGet("userinfo")] | ||||
|     [Authorize] | ||||
|     public async Task<IActionResult> UserInfo() | ||||
|     public async Task<IActionResult> GetUserInfo() | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser || | ||||
|             HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); | ||||
| @@ -175,19 +208,35 @@ public class OidcProviderController( | ||||
|  | ||||
| public class TokenRequest | ||||
| { | ||||
|     [JsonPropertyName("grant_type")] public string? GrantType { get; set; } | ||||
|     [JsonPropertyName("grant_type")] | ||||
|     [FromForm(Name = "grant_type")] | ||||
|     public string? GrantType { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("code")] public string? Code { get; set; } | ||||
|     [JsonPropertyName("code")] | ||||
|     [FromForm(Name = "code")] | ||||
|     public string? Code { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("redirect_uri")] public string? RedirectUri { get; set; } | ||||
|     [JsonPropertyName("redirect_uri")] | ||||
|     [FromForm(Name = "redirect_uri")] | ||||
|     public string? RedirectUri { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("client_id")] public Guid? ClientId { get; set; } | ||||
|     [JsonPropertyName("client_id")] | ||||
|     [FromForm(Name = "client_id")] | ||||
|     public Guid? ClientId { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("client_secret")] public string? ClientSecret { get; set; } | ||||
|     [JsonPropertyName("client_secret")] | ||||
|     [FromForm(Name = "client_secret")] | ||||
|     public string? ClientSecret { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("refresh_token")] public string? RefreshToken { get; set; } | ||||
|     [JsonPropertyName("refresh_token")] | ||||
|     [FromForm(Name = "refresh_token")] | ||||
|     public string? RefreshToken { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("scope")] public string? Scope { get; set; } | ||||
|     [JsonPropertyName("scope")] | ||||
|     [FromForm(Name = "scope")] | ||||
|     public string? Scope { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("code_verifier")] public string? CodeVerifier { get; set; } | ||||
|     [JsonPropertyName("code_verifier")] | ||||
|     [FromForm(Name = "code_verifier")] | ||||
|     public string? CodeVerifier { get; set; } | ||||
| } | ||||
| @@ -13,6 +13,5 @@ public class AuthorizationCodeInfo | ||||
|     public string? CodeChallenge { get; set; } | ||||
|     public string? CodeChallengeMethod { get; set; } | ||||
|     public string? Nonce { get; set; } | ||||
|     public Instant Expiration { get; set; } | ||||
|     public Instant CreatedAt { get; set; } | ||||
| } | ||||
|   | ||||
| @@ -53,34 +53,54 @@ public class OidcProviderService( | ||||
|  | ||||
|     public async Task<TokenResponse> GenerateTokenResponseAsync( | ||||
|         Guid clientId, | ||||
|         string authorizationCode, | ||||
|         IEnumerable<string>? scopes = null | ||||
|         string? authorizationCode = null, | ||||
|         string? redirectUri = null, | ||||
|         string? codeVerifier = null, | ||||
|         Guid? sessionId = null | ||||
|     ) | ||||
|     { | ||||
|         var client = await FindClientByIdAsync(clientId); | ||||
|         if (client == null) | ||||
|             throw new InvalidOperationException("Client not found"); | ||||
|  | ||||
|         var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId); | ||||
|         if (authCode is null) throw new InvalidOperationException("Invalid authorization code"); | ||||
|         var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync(); | ||||
|         if (account is null) throw new InvalidOperationException("Account was not found"); | ||||
|  | ||||
|         Session session; | ||||
|         var clock = SystemClock.Instance; | ||||
|         var now = clock.GetCurrentInstant(); | ||||
|         var session = await auth.CreateSessionAsync(account, now); | ||||
|  | ||||
|         List<string>? scopes = null; | ||||
|         if (authorizationCode != null) | ||||
|         { | ||||
|             // Authorization code flow | ||||
|             var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier); | ||||
|             if (authCode is null) throw new InvalidOperationException("Invalid authorization code"); | ||||
|             var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync(); | ||||
|             if (account is null) throw new InvalidOperationException("Account was not found"); | ||||
|  | ||||
|             session = await auth.CreateSessionForOidcAsync(account, now); | ||||
|             scopes = authCode.Scopes; | ||||
|         } | ||||
|         else if (sessionId.HasValue) | ||||
|         { | ||||
|             // Refresh token flow | ||||
|             session = await FindSessionByIdAsync(sessionId.Value) ??  | ||||
|                      throw new InvalidOperationException("Invalid session"); | ||||
|              | ||||
|             // Verify the session is still valid | ||||
|             if (session.ExpiredAt < now) | ||||
|                 throw new InvalidOperationException("Session has expired"); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             throw new InvalidOperationException("Either authorization code or session ID must be provided"); | ||||
|         } | ||||
|  | ||||
|         var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds; | ||||
|         var expiresAt = now.Plus(Duration.FromSeconds(expiresIn)); | ||||
|  | ||||
|         // Generate access token | ||||
|         // Generate an access token | ||||
|         var accessToken = GenerateJwtToken(client, session, expiresAt, scopes); | ||||
|         var refreshToken = GenerateRefreshToken(session); | ||||
|  | ||||
|         // In a real implementation, you would store the token in the database | ||||
|         // For this example, we'll just return the token without storing it | ||||
|         // as we don't have a dedicated OIDC token table | ||||
|  | ||||
|         return new TokenResponse | ||||
|         { | ||||
|             AccessToken = accessToken, | ||||
| @@ -105,8 +125,8 @@ public class OidcProviderService( | ||||
|         var tokenDescriptor = new SecurityTokenDescriptor | ||||
|         { | ||||
|             Subject = new ClaimsIdentity([ | ||||
|                 new Claim(JwtRegisteredClaimNames.Sub, session.Id.ToString()), | ||||
|             new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), | ||||
|                 new Claim(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()), | ||||
|             new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()), | ||||
|             new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), | ||||
|                 ClaimValueTypes.Integer64), | ||||
|             new Claim("client_id", client.Id.ToString()) | ||||
| @@ -144,7 +164,7 @@ public class OidcProviderService( | ||||
|             { | ||||
|                 ValidateIssuer = true, | ||||
|                 ValidIssuer = _options.IssuerUri, | ||||
|                 ValidateAudience = true, | ||||
|                 ValidateAudience = false, | ||||
|                 ValidateLifetime = true, | ||||
|                 ClockSkew = TimeSpan.Zero | ||||
|             }; | ||||
| @@ -166,9 +186,18 @@ public class OidcProviderService( | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task<Session?> FindSessionByIdAsync(Guid sessionId) | ||||
|     { | ||||
|         return await db.AuthSessions | ||||
|             .Include(s => s.Account) | ||||
|             .Include(s => s.Challenge) | ||||
|             .Include(s => s.App) | ||||
|             .FirstOrDefaultAsync(s => s.Id == sessionId); | ||||
|     } | ||||
|  | ||||
|     private static string GenerateRefreshToken(Session session) | ||||
|     { | ||||
|         return Convert.ToBase64String(Encoding.UTF8.GetBytes(session.Id.ToString())); | ||||
|         return Convert.ToBase64String(session.Id.ToByteArray()); | ||||
|     } | ||||
|  | ||||
|     private static bool VerifyHashedSecret(string secret, string hashedSecret) | ||||
| @@ -202,7 +231,6 @@ public class OidcProviderService( | ||||
|             CodeChallenge = codeChallenge, | ||||
|             CodeChallengeMethod = codeChallengeMethod, | ||||
|             Nonce = nonce, | ||||
|             Expiration = now.Plus(Duration.FromTimeSpan(_options.AuthorizationCodeLifetime)), | ||||
|             CreatedAt = now | ||||
|         }; | ||||
|  | ||||
| @@ -214,7 +242,7 @@ public class OidcProviderService( | ||||
|         return code; | ||||
|     } | ||||
|  | ||||
|     public async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync( | ||||
|     private async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync( | ||||
|         string code, | ||||
|         Guid clientId, | ||||
|         string? redirectUri = null, | ||||
| @@ -230,17 +258,6 @@ public class OidcProviderService( | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var clock = SystemClock.Instance; | ||||
|         var now = clock.GetCurrentInstant(); | ||||
|  | ||||
|         // Check if the code has expired | ||||
|         if (now > authCode.Expiration) | ||||
|         { | ||||
|             logger.LogWarning("Authorization code expired: {Code}", code); | ||||
|             await cache.RemoveAsync(cacheKey); | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         // Verify client ID matches | ||||
|         if (authCode.ClientId != clientId) | ||||
|         { | ||||
|   | ||||
| @@ -376,7 +376,7 @@ public class ConnectionController( | ||||
|  | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         var loginSession = await auth.CreateSessionAsync(account, clock.GetCurrentInstant()); | ||||
|         var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant()); | ||||
|         var loginToken = auth.CreateToken(loginSession); | ||||
|         return Redirect($"/auth/token?token={loginToken}"); | ||||
|     } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using System.Text.Json.Serialization; | ||||
| using DysonNetwork.Sphere.Developer; | ||||
| using NodaTime; | ||||
| using Point = NetTopologySuite.Geometries.Point; | ||||
|  | ||||
| @@ -17,6 +18,8 @@ public class Session : ModelBase | ||||
|     [JsonIgnore] public Account.Account Account { get; set; } = null!; | ||||
|     public Guid ChallengeId { get; set; } | ||||
|     public Challenge Challenge { get; set; } = null!; | ||||
|     public Guid? AppId { get; set; } | ||||
|     public CustomApp? App { get; set; } | ||||
| } | ||||
|  | ||||
| public enum ChallengeType | ||||
|   | ||||
		Reference in New Issue
	
	Block a user