♻️ Refactored oidc onboard flow
This commit is contained in:
@@ -5,7 +5,8 @@ namespace DysonNetwork.Pass.Auth.OidcProvider.Models;
|
|||||||
public class AuthorizationCodeInfo
|
public class AuthorizationCodeInfo
|
||||||
{
|
{
|
||||||
public Guid ClientId { get; set; }
|
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 string RedirectUri { get; set; } = string.Empty;
|
||||||
public List<string> Scopes { get; set; } = new();
|
public List<string> Scopes { get; set; } = new();
|
||||||
public string? CodeChallenge { get; set; }
|
public string? CodeChallenge { get; set; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
|
|||||||
public class TokenResponse
|
public class TokenResponse
|
||||||
{
|
{
|
||||||
[JsonPropertyName("access_token")]
|
[JsonPropertyName("access_token")]
|
||||||
public string AccessToken { get; set; } = null!;
|
public string? AccessToken { get; set; } = null!;
|
||||||
|
|
||||||
[JsonPropertyName("expires_in")]
|
[JsonPropertyName("expires_in")]
|
||||||
public int ExpiresIn { get; set; }
|
public int ExpiresIn { get; set; }
|
||||||
@@ -22,4 +22,7 @@ public class TokenResponse
|
|||||||
|
|
||||||
[JsonPropertyName("id_token")]
|
[JsonPropertyName("id_token")]
|
||||||
public string? IdToken { get; set; }
|
public string? IdToken { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("onboarding_token")]
|
||||||
|
public string? OnboardingToken { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,18 +257,15 @@ public class OidcProviderService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(SnAuthSession session, string? nonce, List<string>? scopes)> HandleAuthorizationCodeFlowAsync(
|
private async Task<(SnAuthSession session, string? nonce, List<string>? scopes)> HandleAuthorizationCodeFlowAsync(
|
||||||
string authorizationCode,
|
AuthorizationCodeInfo authCode,
|
||||||
Guid clientId,
|
Guid clientId
|
||||||
string? redirectUri,
|
|
||||||
string? codeVerifier
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
|
if (authCode.AccountId == null)
|
||||||
if (authCode == null)
|
throw new InvalidOperationException("Invalid authorization code, account id is missing.");
|
||||||
throw new InvalidOperationException("Invalid authorization code");
|
|
||||||
|
|
||||||
// Load the session for the user
|
// 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;
|
SnAuthSession session;
|
||||||
if (existingSession == null)
|
if (existingSession == null)
|
||||||
@@ -315,31 +312,124 @@ public class OidcProviderService(
|
|||||||
|
|
||||||
var client = await FindClientByIdAsync(clientId) ?? throw new InvalidOperationException("Client not found");
|
var client = await FindClientByIdAsync(clientId) ?? throw new InvalidOperationException("Client not found");
|
||||||
|
|
||||||
var (session, nonce, scopes) = authorizationCode != null
|
if (authorizationCode != null)
|
||||||
? await HandleAuthorizationCodeFlowAsync(authorizationCode, clientId, redirectUri, codeVerifier)
|
{
|
||||||
: sessionId.HasValue
|
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
|
||||||
? await HandleRefreshTokenFlowAsync(sessionId.Value)
|
if (authCode == null)
|
||||||
: throw new InvalidOperationException("Either authorization code or session ID must be provided");
|
{
|
||||||
|
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<string> scopes)
|
||||||
|
{
|
||||||
|
var tokenHandler = new JwtSecurityTokenHandler();
|
||||||
var clock = SystemClock.Instance;
|
var clock = SystemClock.Instance;
|
||||||
var now = clock.GetCurrentInstant();
|
var now = clock.GetCurrentInstant();
|
||||||
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
|
|
||||||
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
|
|
||||||
|
|
||||||
// Generate tokens
|
var claims = new List<Claim>
|
||||||
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
|
|
||||||
var idToken = GenerateIdToken(client, session, nonce, scopes);
|
|
||||||
var refreshToken = GenerateRefreshToken(session);
|
|
||||||
|
|
||||||
return new TokenResponse
|
|
||||||
{
|
{
|
||||||
AccessToken = accessToken,
|
new(JwtRegisteredClaimNames.Iss, _options.IssuerUri),
|
||||||
IdToken = idToken,
|
new(JwtRegisteredClaimNames.Aud, client.Slug),
|
||||||
ExpiresIn = expiresIn,
|
new(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
|
||||||
TokenType = "Bearer",
|
new(JwtRegisteredClaimNames.Exp,
|
||||||
RefreshToken = refreshToken,
|
now.Plus(Duration.FromMinutes(15)).ToUnixTimeSeconds()
|
||||||
Scope = scopes != null ? string.Join(" ", scopes) : null
|
.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(
|
private string GenerateJwtToken(
|
||||||
@@ -440,12 +530,6 @@ public class OidcProviderService(
|
|||||||
string? nonce = null
|
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
|
var authCodeInfo = new AuthorizationCodeInfo
|
||||||
{
|
{
|
||||||
ClientId = clientId,
|
ClientId = clientId,
|
||||||
@@ -455,17 +539,47 @@ public class OidcProviderService(
|
|||||||
CodeChallenge = codeChallenge,
|
CodeChallenge = codeChallenge,
|
||||||
CodeChallengeMethod = codeChallengeMethod,
|
CodeChallengeMethod = codeChallengeMethod,
|
||||||
Nonce = nonce,
|
Nonce = nonce,
|
||||||
CreatedAt = now
|
CreatedAt = SystemClock.Instance.GetCurrentInstant()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store the code with its metadata in the cache
|
return await StoreAuthorizationCode(authCodeInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GenerateAuthorizationCodeAsync(
|
||||||
|
Guid clientId,
|
||||||
|
ExternalUserInfo externalUserInfo,
|
||||||
|
string redirectUri,
|
||||||
|
IEnumerable<string> 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<string> StoreAuthorizationCode(AuthorizationCodeInfo authCodeInfo)
|
||||||
|
{
|
||||||
|
var code = GenerateRandomString(32);
|
||||||
var cacheKey = $"{CacheKeyPrefixAuthCode}{code}";
|
var cacheKey = $"{CacheKeyPrefixAuthCode}{code}";
|
||||||
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
|
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
|
||||||
|
logger.LogInformation("Generated authorization code for client {ClientId}", authCodeInfo.ClientId);
|
||||||
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
|
|
||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync(
|
private async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync(
|
||||||
string code,
|
string code,
|
||||||
Guid clientId,
|
Guid clientId,
|
||||||
|
|||||||
@@ -166,9 +166,11 @@ public class ConnectionController(
|
|||||||
{
|
{
|
||||||
callbackData.State = oidcState.DeviceId;
|
callbackData.State = oidcState.DeviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await HandleManualConnection(provider, oidcService, callbackData, oidcState.AccountId.Value);
|
return await HandleManualConnection(provider, oidcService, callbackData, oidcState.AccountId.Value);
|
||||||
}
|
}
|
||||||
else if (oidcState.FlowType == OidcFlowType.Login)
|
|
||||||
|
if (oidcState.FlowType == OidcFlowType.Login)
|
||||||
{
|
{
|
||||||
// Login/Registration flow
|
// Login/Registration flow
|
||||||
if (!string.IsNullOrEmpty(oidcState.DeviceId))
|
if (!string.IsNullOrEmpty(oidcState.DeviceId))
|
||||||
@@ -309,6 +311,7 @@ public class ConnectionController(
|
|||||||
.FirstOrDefaultAsync(c => c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId);
|
.FirstOrDefaultAsync(c => c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId);
|
||||||
|
|
||||||
var clock = SystemClock.Instance;
|
var clock = SystemClock.Instance;
|
||||||
|
var siteUrl = configuration["SiteUrl"];
|
||||||
if (connection != null)
|
if (connection != null)
|
||||||
{
|
{
|
||||||
// Login existing user
|
// Login existing user
|
||||||
@@ -321,7 +324,8 @@ public class ConnectionController(
|
|||||||
connection.Account,
|
connection.Account,
|
||||||
HttpContext,
|
HttpContext,
|
||||||
deviceId ?? string.Empty);
|
deviceId ?? string.Empty);
|
||||||
return Redirect($"/auth/callback?challenge={challenge.Id}");
|
|
||||||
|
return Redirect(siteUrl + $"/auth/callback?challenge={challenge.Id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register new user
|
// Register new user
|
||||||
@@ -345,8 +349,6 @@ public class ConnectionController(
|
|||||||
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
|
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
|
||||||
var loginToken = auth.CreateToken(loginSession);
|
var loginToken = auth.CreateToken(loginSession);
|
||||||
|
|
||||||
var siteUrl = configuration["SiteUrl"];
|
|
||||||
|
|
||||||
return Redirect(siteUrl + $"/auth/callback?token={loginToken}");
|
return Redirect(siteUrl + $"/auth/callback?token={loginToken}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
63
ONBOARDING_FLOW.md
Normal file
63
ONBOARDING_FLOW.md
Normal file
@@ -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.
|
||||||
Reference in New Issue
Block a user