From 6fd90c424d8f44a489f2ba40e655d8a16733ae0b Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 16 Nov 2025 15:05:29 +0800 Subject: [PATCH] :recycle: Refactored oidc onboard flow --- .../Models/AuthorizationCodeInfo.cs | 3 +- .../OidcProvider/Models/ExternalUserInfo.cs | 9 + .../OidcProvider/Responses/TokenResponse.cs | 5 +- .../Services/OidcProviderService.cs | 188 ++++++++++++++---- .../Auth/OpenId/ConnectionController.cs | 10 +- ONBOARDING_FLOW.md | 63 ++++++ 6 files changed, 235 insertions(+), 43 deletions(-) create mode 100644 DysonNetwork.Pass/Auth/OidcProvider/Models/ExternalUserInfo.cs create mode 100644 ONBOARDING_FLOW.md diff --git a/DysonNetwork.Pass/Auth/OidcProvider/Models/AuthorizationCodeInfo.cs b/DysonNetwork.Pass/Auth/OidcProvider/Models/AuthorizationCodeInfo.cs index 14bc397..25d830d 100644 --- a/DysonNetwork.Pass/Auth/OidcProvider/Models/AuthorizationCodeInfo.cs +++ b/DysonNetwork.Pass/Auth/OidcProvider/Models/AuthorizationCodeInfo.cs @@ -5,7 +5,8 @@ namespace DysonNetwork.Pass.Auth.OidcProvider.Models; public class AuthorizationCodeInfo { public Guid ClientId { get; set; } - public Guid AccountId { get; set; } + public Guid? AccountId { get; set; } + public ExternalUserInfo? ExternalUserInfo { get; set; } public string RedirectUri { get; set; } = string.Empty; public List Scopes { get; set; } = new(); public string? CodeChallenge { get; set; } diff --git a/DysonNetwork.Pass/Auth/OidcProvider/Models/ExternalUserInfo.cs b/DysonNetwork.Pass/Auth/OidcProvider/Models/ExternalUserInfo.cs new file mode 100644 index 0000000..24ab9ad --- /dev/null +++ b/DysonNetwork.Pass/Auth/OidcProvider/Models/ExternalUserInfo.cs @@ -0,0 +1,9 @@ +namespace DysonNetwork.Pass.Auth.OidcProvider.Models; + +public class ExternalUserInfo +{ + public string Provider { get; set; } = null!; + public string UserId { get; set; } = null!; + public string? Email { get; set; } + public string? Name { get; set; } +} diff --git a/DysonNetwork.Pass/Auth/OidcProvider/Responses/TokenResponse.cs b/DysonNetwork.Pass/Auth/OidcProvider/Responses/TokenResponse.cs index 1eecfeb..cf6eec7 100644 --- a/DysonNetwork.Pass/Auth/OidcProvider/Responses/TokenResponse.cs +++ b/DysonNetwork.Pass/Auth/OidcProvider/Responses/TokenResponse.cs @@ -5,7 +5,7 @@ namespace DysonNetwork.Pass.Auth.OidcProvider.Responses; public class TokenResponse { [JsonPropertyName("access_token")] - public string AccessToken { get; set; } = null!; + public string? AccessToken { get; set; } = null!; [JsonPropertyName("expires_in")] public int ExpiresIn { get; set; } @@ -22,4 +22,7 @@ public class TokenResponse [JsonPropertyName("id_token")] public string? IdToken { get; set; } + + [JsonPropertyName("onboarding_token")] + public string? OnboardingToken { get; set; } } diff --git a/DysonNetwork.Pass/Auth/OidcProvider/Services/OidcProviderService.cs b/DysonNetwork.Pass/Auth/OidcProvider/Services/OidcProviderService.cs index 6dd764d..a92f667 100644 --- a/DysonNetwork.Pass/Auth/OidcProvider/Services/OidcProviderService.cs +++ b/DysonNetwork.Pass/Auth/OidcProvider/Services/OidcProviderService.cs @@ -257,18 +257,15 @@ public class OidcProviderService( } private async Task<(SnAuthSession session, string? nonce, List? scopes)> HandleAuthorizationCodeFlowAsync( - string authorizationCode, - Guid clientId, - string? redirectUri, - string? codeVerifier + AuthorizationCodeInfo authCode, + Guid clientId ) { - var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier); - if (authCode == null) - throw new InvalidOperationException("Invalid authorization code"); + if (authCode.AccountId == null) + throw new InvalidOperationException("Invalid authorization code, account id is missing."); // Load the session for the user - var existingSession = await FindValidSessionAsync(authCode.AccountId, clientId, withAccount: true); + var existingSession = await FindValidSessionAsync(authCode.AccountId.Value, clientId, withAccount: true); SnAuthSession session; if (existingSession == null) @@ -315,31 +312,124 @@ public class OidcProviderService( var client = await FindClientByIdAsync(clientId) ?? throw new InvalidOperationException("Client not found"); - var (session, nonce, scopes) = authorizationCode != null - ? await HandleAuthorizationCodeFlowAsync(authorizationCode, clientId, redirectUri, codeVerifier) - : sessionId.HasValue - ? await HandleRefreshTokenFlowAsync(sessionId.Value) - : throw new InvalidOperationException("Either authorization code or session ID must be provided"); + if (authorizationCode != null) + { + var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier); + if (authCode == null) + { + throw new InvalidOperationException("Invalid authorization code"); + } + if (authCode.AccountId.HasValue) + { + var (session, nonce, scopes) = await HandleAuthorizationCodeFlowAsync(authCode, clientId); + var clock = SystemClock.Instance; + var now = clock.GetCurrentInstant(); + var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds; + var expiresAt = now.Plus(Duration.FromSeconds(expiresIn)); + + // Generate tokens + var accessToken = GenerateJwtToken(client, session, expiresAt, scopes); + var idToken = GenerateIdToken(client, session, nonce, scopes); + var refreshToken = GenerateRefreshToken(session); + + return new TokenResponse + { + AccessToken = accessToken, + IdToken = idToken, + ExpiresIn = expiresIn, + TokenType = "Bearer", + RefreshToken = refreshToken, + Scope = scopes != null ? string.Join(" ", scopes) : null + }; + } + + if (authCode.ExternalUserInfo != null) + { + var onboardingToken = GenerateOnboardingToken(client, authCode.ExternalUserInfo, authCode.Nonce, authCode.Scopes); + return new TokenResponse + { + OnboardingToken = onboardingToken, + TokenType = "Onboarding" + }; + } + + throw new InvalidOperationException("Invalid authorization code state."); + } + + if (sessionId.HasValue) + { + var (session, nonce, scopes) = await HandleRefreshTokenFlowAsync(sessionId.Value); + var clock = SystemClock.Instance; + var now = clock.GetCurrentInstant(); + var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds; + var expiresAt = now.Plus(Duration.FromSeconds(expiresIn)); + var accessToken = GenerateJwtToken(client, session, expiresAt, scopes); + var idToken = GenerateIdToken(client, session, nonce, scopes); + var refreshToken = GenerateRefreshToken(session); + return new TokenResponse + { + AccessToken = accessToken, + IdToken = idToken, + ExpiresIn = expiresIn, + TokenType = "Bearer", + RefreshToken = refreshToken, + Scope = scopes != null ? string.Join(" ", scopes) : null + }; + } + + throw new InvalidOperationException("Either authorization code or session ID must be provided"); + } + + private string GenerateOnboardingToken(CustomApp client, ExternalUserInfo externalUserInfo, string? nonce, + List scopes) + { + var tokenHandler = new JwtSecurityTokenHandler(); var clock = SystemClock.Instance; var now = clock.GetCurrentInstant(); - var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds; - var expiresAt = now.Plus(Duration.FromSeconds(expiresIn)); - // Generate tokens - var accessToken = GenerateJwtToken(client, session, expiresAt, scopes); - var idToken = GenerateIdToken(client, session, nonce, scopes); - var refreshToken = GenerateRefreshToken(session); - - return new TokenResponse + var claims = new List { - AccessToken = accessToken, - IdToken = idToken, - ExpiresIn = expiresIn, - TokenType = "Bearer", - RefreshToken = refreshToken, - Scope = scopes != null ? string.Join(" ", scopes) : null + new(JwtRegisteredClaimNames.Iss, _options.IssuerUri), + new(JwtRegisteredClaimNames.Aud, client.Slug), + new(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64), + new(JwtRegisteredClaimNames.Exp, + now.Plus(Duration.FromMinutes(15)).ToUnixTimeSeconds() + .ToString(), ClaimValueTypes.Integer64), + new("provider", externalUserInfo.Provider), + new("provider_user_id", externalUserInfo.UserId) }; + + if (!string.IsNullOrEmpty(externalUserInfo.Email)) + { + claims.Add(new Claim(JwtRegisteredClaimNames.Email, externalUserInfo.Email)); + } + + if (!string.IsNullOrEmpty(externalUserInfo.Name)) + { + claims.Add(new Claim("name", externalUserInfo.Name)); + } + + if (!string.IsNullOrEmpty(nonce)) + { + claims.Add(new Claim("nonce", nonce)); + } + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Issuer = _options.IssuerUri, + Audience = client.Slug, + Expires = now.Plus(Duration.FromMinutes(15)).ToDateTimeUtc(), + NotBefore = now.ToDateTimeUtc(), + SigningCredentials = new SigningCredentials( + new RsaSecurityKey(_options.GetRsaPrivateKey()), + SecurityAlgorithms.RsaSha256 + ) + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); } private string GenerateJwtToken( @@ -440,12 +530,6 @@ public class OidcProviderService( string? nonce = null ) { - // Generate a random code - var clock = SystemClock.Instance; - var code = GenerateRandomString(32); - var now = clock.GetCurrentInstant(); - - // Create the authorization code info var authCodeInfo = new AuthorizationCodeInfo { ClientId = clientId, @@ -455,17 +539,47 @@ public class OidcProviderService( CodeChallenge = codeChallenge, CodeChallengeMethod = codeChallengeMethod, Nonce = nonce, - CreatedAt = now + CreatedAt = SystemClock.Instance.GetCurrentInstant() }; - // Store the code with its metadata in the cache + return await StoreAuthorizationCode(authCodeInfo); + } + + public async Task GenerateAuthorizationCodeAsync( + Guid clientId, + ExternalUserInfo externalUserInfo, + string redirectUri, + IEnumerable scopes, + string? codeChallenge = null, + string? codeChallengeMethod = null, + string? nonce = null + ) + { + var authCodeInfo = new AuthorizationCodeInfo + { + ClientId = clientId, + ExternalUserInfo = externalUserInfo, + RedirectUri = redirectUri, + Scopes = scopes.ToList(), + CodeChallenge = codeChallenge, + CodeChallengeMethod = codeChallengeMethod, + Nonce = nonce, + CreatedAt = SystemClock.Instance.GetCurrentInstant() + }; + + return await StoreAuthorizationCode(authCodeInfo); + } + + private async Task StoreAuthorizationCode(AuthorizationCodeInfo authCodeInfo) + { + var code = GenerateRandomString(32); var cacheKey = $"{CacheKeyPrefixAuthCode}{code}"; await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime); - - logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId); + logger.LogInformation("Generated authorization code for client {ClientId}", authCodeInfo.ClientId); return code; } + private async Task ValidateAuthorizationCodeAsync( string code, Guid clientId, diff --git a/DysonNetwork.Pass/Auth/OpenId/ConnectionController.cs b/DysonNetwork.Pass/Auth/OpenId/ConnectionController.cs index 93f7157..94f367b 100644 --- a/DysonNetwork.Pass/Auth/OpenId/ConnectionController.cs +++ b/DysonNetwork.Pass/Auth/OpenId/ConnectionController.cs @@ -166,9 +166,11 @@ public class ConnectionController( { callbackData.State = oidcState.DeviceId; } + return await HandleManualConnection(provider, oidcService, callbackData, oidcState.AccountId.Value); } - else if (oidcState.FlowType == OidcFlowType.Login) + + if (oidcState.FlowType == OidcFlowType.Login) { // Login/Registration flow if (!string.IsNullOrEmpty(oidcState.DeviceId)) @@ -309,6 +311,7 @@ public class ConnectionController( .FirstOrDefaultAsync(c => c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId); var clock = SystemClock.Instance; + var siteUrl = configuration["SiteUrl"]; if (connection != null) { // Login existing user @@ -321,7 +324,8 @@ public class ConnectionController( connection.Account, HttpContext, deviceId ?? string.Empty); - return Redirect($"/auth/callback?challenge={challenge.Id}"); + + return Redirect(siteUrl + $"/auth/callback?challenge={challenge.Id}"); } // Register new user @@ -345,8 +349,6 @@ public class ConnectionController( var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant()); var loginToken = auth.CreateToken(loginSession); - var siteUrl = configuration["SiteUrl"]; - return Redirect(siteUrl + $"/auth/callback?token={loginToken}"); } diff --git a/ONBOARDING_FLOW.md b/ONBOARDING_FLOW.md new file mode 100644 index 0000000..922c7ed --- /dev/null +++ b/ONBOARDING_FLOW.md @@ -0,0 +1,63 @@ +# Client-Side Onboarding Flow for New Users + +This document outlines the steps for a client application to handle the onboarding of new users who authenticate via a third-party provider. + +## 1. Initiate the OIDC Login Flow + +This step remains the same as a standard OIDC authorization code flow. The client application redirects the user to the `/authorize` endpoint of the authentication server with the required parameters (`response_type=code`, `client_id`, `redirect_uri`, `scope`, etc.). + +## 2. Handle the Token Response + +After the user authenticates with the third-party provider and is redirected back to the client, the client will have an `authorization_code`. The client then exchanges this code for tokens at the `/token` endpoint. + +The response from the `/token` endpoint will differ for new and existing users. + +### For Existing Users + +If the user already has an account, the token response will be a standard OIDC token response, containing: +- `access_token` +- `id_token` +- `refresh_token` +- `expires_in` +- `token_type: "Bearer"` + +The client should proceed with the standard login flow. + +### For New Users + +If the user is new, the token response will contain a special `onboarding_token`: +- `onboarding_token`: A JWT containing information about the new user from the external provider. +- `token_type: "Onboarding"` + +The presence of the `onboarding_token` is the signal for the client to start the new user onboarding flow. + +## 3. Process the Onboarding Token + +The `onboarding_token` is a JWT. The client should decode it to access the claims, which will include: + +- `provider`: The name of the external provider (e.g., "Google", "Facebook"). +- `provider_user_id`: The user's unique ID from the external provider. +- `email`: The user's email address (if available). +- `name`: The user's full name from the external provider (if available). +- `nonce`: The nonce from the initial authorization request. + +Using this information, the client can now guide the user through a custom onboarding process. For example, it can pre-fill a registration form with the user's name and email, and prompt the user to choose a unique username for their new account. + +## 4. Complete the Onboarding + +To finalize the account creation, the client needs to send the collected information to the server. This requires a new API endpoint on the server that is not part of this change. + +**Example Endpoint:** `POST /api/account/onboard` + +The client would send a request to this endpoint, including: +- The `onboarding_token`. +- The username chosen by the user. +- Any other required information. + +The server will validate the `onboarding_token` and create a new user account with the provided details. + +## 5. Finalize Login + +Upon successful account creation, the server's onboarding endpoint should return a standard set of OIDC tokens (`access_token`, `id_token`, `refresh_token`) for the newly created user. + +The client can then use these tokens to log the user in, completing the onboarding and login process.