feat(thalos-bff): add google oidc start and callback flow
This commit is contained in:
parent
12cb75783b
commit
730abb95ec
@ -12,6 +12,9 @@
|
||||
- `POST /api/identity/session/refresh`
|
||||
- `POST /api/identity/session/logout`
|
||||
- `GET /api/identity/session/me`
|
||||
- Canonical OIDC endpoints:
|
||||
- `GET /api/identity/oidc/google/start`
|
||||
- `GET /api/identity/oidc/google/callback`
|
||||
- Compatibility endpoint:
|
||||
- `POST /api/identity/token`
|
||||
- `POST /api/identity/login`
|
||||
@ -22,7 +25,8 @@
|
||||
|
||||
- Endpoint handlers perform edge validation and permission checks.
|
||||
- Session login and refresh call canonical thalos-service session gRPC operations.
|
||||
- Session cookies are managed at the BFF edge (`thalos_session`, `thalos_refresh`) with env-driven secure flag.
|
||||
- OIDC start/callback handlers generate and validate PKCE/state/nonce payloads.
|
||||
- Session cookies are managed at the BFF edge (`thalos_session`, `thalos_refresh`) with env-driven secure/domain policy.
|
||||
- Token issuance and policy evaluation contracts remain available for compatibility calls.
|
||||
- Business orchestration remains in thalos-service.
|
||||
- Identity abstractions remain owned by Thalos repositories.
|
||||
|
||||
@ -9,8 +9,11 @@ Keep thalos-bff as an edge adapter layer that consumes thalos-service and adopte
|
||||
- Correlation/tracing propagation
|
||||
- Single active edge protocol policy enforcement (`rest`)
|
||||
- Provider metadata propagation (`InternalJwt`, `AzureAd`, `Google`)
|
||||
- OIDC edge flow orchestration (Google start/callback with PKCE/state/nonce)
|
||||
- Session-cookie issuance policy (secure/domain settings for cross-subdomain web auth)
|
||||
|
||||
## Prohibited
|
||||
- Direct DAL access
|
||||
- Identity policy decision ownership
|
||||
- Identity persistence concerns
|
||||
- Provider secret-manager coupling inside domain/service logic
|
||||
|
||||
@ -11,7 +11,15 @@ docker build --build-arg NUGET_FEED_USERNAME=<gitea-login> --build-arg NUGET
|
||||
## Local Run
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8080:8080 --name thalos-bff agilewebs/thalos-bff:dev
|
||||
docker run --rm -p 8080:8080 \
|
||||
-e ThalosService__GrpcAddress=http://thalos-service:8081 \
|
||||
-e ThalosBff__Oidc__Google__ClientId=<google-client-id> \
|
||||
-e ThalosBff__Oidc__Google__ClientSecret=<google-client-secret> \
|
||||
-e ThalosBff__Oidc__Google__RedirectUri=https://auth.dream-views.com/api/identity/oidc/google/callback \
|
||||
-e ThalosBff__Oidc__StateSigningSecret=<state-signing-secret> \
|
||||
-e ThalosBff__SessionCookieSecure=true \
|
||||
-e ThalosBff__SessionCookieDomain=.dream-views.com \
|
||||
--name thalos-bff agilewebs/thalos-bff:dev
|
||||
```
|
||||
|
||||
## Health Probe
|
||||
@ -24,3 +32,5 @@ docker run --rm -p 8080:8080 --name thalos-bff agilewebs/thalos-bff:dev
|
||||
|
||||
- Requires `ThalosService__GrpcAddress` to target thalos-service in distributed runs.
|
||||
- gRPC client contract protobuf is vendored at `src/Thalos.Bff.Rest/Protos/identity_runtime.proto` to keep image builds repo-local.
|
||||
- OIDC callback requires `ThalosBff__Oidc__Google__ClientId`, `ClientSecret`, `RedirectUri`, and `StateSigningSecret`.
|
||||
- For cross-subdomain SPA auth, set `ThalosBff__SessionCookieDomain=.dream-views.com` and secure cookies in non-local environments.
|
||||
|
||||
@ -0,0 +1,296 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Thalos.Bff.Application.Security.Oidc;
|
||||
|
||||
/// <summary>
|
||||
/// Builds and validates Google OIDC edge-flow artifacts (PKCE, state, nonce, and return-url policy).
|
||||
/// </summary>
|
||||
public sealed class GoogleOidcFlowService(
|
||||
GoogleOidcOptions options,
|
||||
string stateSigningSecret)
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the OIDC authorization redirect and signed state payload for callback validation.
|
||||
/// </summary>
|
||||
/// <param name="requestedReturnUrl">Optional caller return URL.</param>
|
||||
/// <param name="requestedTenantId">Optional tenant override.</param>
|
||||
/// <returns>Start-flow context for edge redirect and callback validation.</returns>
|
||||
public GoogleOidcStartContext BuildStartContext(string? requestedReturnUrl, string? requestedTenantId)
|
||||
{
|
||||
ValidateConfiguration();
|
||||
|
||||
var state = GenerateRandomToken(32);
|
||||
var nonce = GenerateRandomToken(32);
|
||||
var codeVerifier = GenerateRandomToken(64);
|
||||
var codeChallenge = ToBase64Url(SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier)));
|
||||
var returnUrl = ResolveReturnUrl(requestedReturnUrl);
|
||||
var tenantId = string.IsNullOrWhiteSpace(requestedTenantId) ? options.DefaultTenantId : requestedTenantId.Trim();
|
||||
|
||||
var payload = new GoogleOidcStatePayload(
|
||||
state,
|
||||
nonce,
|
||||
codeVerifier,
|
||||
returnUrl,
|
||||
tenantId,
|
||||
DateTimeOffset.UtcNow.Add(options.StateLifetime));
|
||||
var encodedState = EncodeState(payload);
|
||||
|
||||
var query = new Dictionary<string, string?>
|
||||
{
|
||||
["client_id"] = options.ClientId,
|
||||
["redirect_uri"] = options.RedirectUri,
|
||||
["response_type"] = "code",
|
||||
["scope"] = string.IsNullOrWhiteSpace(options.Scope) ? "openid profile email" : options.Scope,
|
||||
["state"] = state,
|
||||
["nonce"] = nonce,
|
||||
["code_challenge"] = codeChallenge,
|
||||
["code_challenge_method"] = "S256",
|
||||
["access_type"] = "offline",
|
||||
["prompt"] = "consent"
|
||||
};
|
||||
|
||||
var authorizationUrl = BuildUrlWithQueryString(options.AuthorizationEndpoint, query);
|
||||
return new GoogleOidcStartContext(authorizationUrl, encodedState, payload);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates callback state against signed payload.
|
||||
/// </summary>
|
||||
/// <param name="encodedState">Signed state payload from cookie.</param>
|
||||
/// <param name="callbackState">State value received from callback query.</param>
|
||||
/// <param name="payload">Validated payload.</param>
|
||||
/// <returns><c>true</c> when callback state is valid and not expired.</returns>
|
||||
public bool TryValidateCallbackState(string? encodedState, string? callbackState, out GoogleOidcStatePayload payload)
|
||||
{
|
||||
payload = default!;
|
||||
if (string.IsNullOrWhiteSpace(encodedState) || string.IsNullOrWhiteSpace(callbackState))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryDecodeState(encodedState, out var decoded))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(decoded.State, callbackState, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (decoded.ExpiresAtUtc <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
payload = decoded;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves default return URL for callback failure paths.
|
||||
/// </summary>
|
||||
/// <returns>Default return URL.</returns>
|
||||
public string GetDefaultReturnUrl()
|
||||
{
|
||||
return options.DefaultReturnUrl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets token endpoint URI configured for Google code exchange.
|
||||
/// </summary>
|
||||
/// <returns>Token endpoint URI.</returns>
|
||||
public string GetTokenEndpoint()
|
||||
{
|
||||
return options.TokenEndpoint;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds token exchange form values for authorization-code callback.
|
||||
/// </summary>
|
||||
/// <param name="code">Authorization code from provider callback.</param>
|
||||
/// <param name="codeVerifier">PKCE code verifier generated during start flow.</param>
|
||||
/// <returns>Form values for token endpoint exchange.</returns>
|
||||
public IReadOnlyDictionary<string, string> BuildTokenExchangeForm(string code, string codeVerifier)
|
||||
{
|
||||
ValidateConfiguration();
|
||||
return new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["grant_type"] = "authorization_code",
|
||||
["code"] = code,
|
||||
["redirect_uri"] = options.RedirectUri,
|
||||
["client_id"] = options.ClientId,
|
||||
["client_secret"] = options.ClientSecret,
|
||||
["code_verifier"] = codeVerifier
|
||||
};
|
||||
}
|
||||
|
||||
private void ValidateConfiguration()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.ClientId) ||
|
||||
string.IsNullOrWhiteSpace(options.ClientSecret) ||
|
||||
string.IsNullOrWhiteSpace(options.RedirectUri) ||
|
||||
string.IsNullOrWhiteSpace(options.AuthorizationEndpoint) ||
|
||||
string.IsNullOrWhiteSpace(options.TokenEndpoint) ||
|
||||
string.IsNullOrWhiteSpace(options.DefaultReturnUrl) ||
|
||||
string.IsNullOrWhiteSpace(options.DefaultTenantId) ||
|
||||
string.IsNullOrWhiteSpace(stateSigningSecret))
|
||||
{
|
||||
throw new InvalidOperationException("Google OIDC configuration is incomplete.");
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveReturnUrl(string? requestedReturnUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(requestedReturnUrl))
|
||||
{
|
||||
return options.DefaultReturnUrl;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(requestedReturnUrl, UriKind.Absolute, out var absoluteUri))
|
||||
{
|
||||
return options.DefaultReturnUrl;
|
||||
}
|
||||
|
||||
if (options.AllowedReturnHosts.Count == 0)
|
||||
{
|
||||
return options.DefaultReturnUrl;
|
||||
}
|
||||
|
||||
foreach (var host in options.AllowedReturnHosts)
|
||||
{
|
||||
if (string.Equals(host.Trim(), absoluteUri.Host, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return absoluteUri.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
return options.DefaultReturnUrl;
|
||||
}
|
||||
|
||||
private string EncodeState(GoogleOidcStatePayload payload)
|
||||
{
|
||||
var payloadJson = JsonSerializer.Serialize(payload);
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
|
||||
var payloadToken = ToBase64Url(payloadBytes);
|
||||
var signature = Sign(payloadToken);
|
||||
return $"{payloadToken}.{signature}";
|
||||
}
|
||||
|
||||
private bool TryDecodeState(string encodedState, out GoogleOidcStatePayload payload)
|
||||
{
|
||||
payload = default!;
|
||||
|
||||
var segments = encodedState.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (segments.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!VerifySignature(segments[0], segments[1]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payloadBytes = FromBase64Url(segments[0]);
|
||||
var deserialized = JsonSerializer.Deserialize<GoogleOidcStatePayload>(payloadBytes);
|
||||
if (deserialized is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
payload = deserialized;
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private string Sign(string payloadToken)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(stateSigningSecret));
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payloadToken));
|
||||
return ToBase64Url(hash);
|
||||
}
|
||||
|
||||
private bool VerifySignature(string payloadToken, string signatureToken)
|
||||
{
|
||||
var expected = Sign(payloadToken);
|
||||
var left = Encoding.UTF8.GetBytes(expected);
|
||||
var right = Encoding.UTF8.GetBytes(signatureToken);
|
||||
return CryptographicOperations.FixedTimeEquals(left, right);
|
||||
}
|
||||
|
||||
private static string GenerateRandomToken(int bytesLength)
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(bytesLength);
|
||||
return ToBase64Url(bytes);
|
||||
}
|
||||
|
||||
private static string ToBase64Url(byte[] bytes)
|
||||
{
|
||||
var encoded = Convert.ToBase64String(bytes);
|
||||
return encoded.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
|
||||
private static byte[] FromBase64Url(string value)
|
||||
{
|
||||
var normalized = value.Replace('-', '+').Replace('_', '/');
|
||||
var remainder = normalized.Length % 4;
|
||||
if (remainder > 0)
|
||||
{
|
||||
normalized = normalized.PadRight(normalized.Length + (4 - remainder), '=');
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(normalized);
|
||||
}
|
||||
|
||||
private static string BuildUrlWithQueryString(string baseUrl, IReadOnlyDictionary<string, string?> parameters)
|
||||
{
|
||||
var encoded = parameters
|
||||
.Where(parameter => !string.IsNullOrWhiteSpace(parameter.Value))
|
||||
.Select(parameter =>
|
||||
$"{Uri.EscapeDataString(parameter.Key)}={Uri.EscapeDataString(parameter.Value!)}");
|
||||
var query = string.Join("&", encoded);
|
||||
var separator = baseUrl.Contains('?') ? "&" : "?";
|
||||
return $"{baseUrl}{separator}{query}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OIDC start context for redirect/callback flow.
|
||||
/// </summary>
|
||||
/// <param name="AuthorizationUrl">OIDC provider authorization URL.</param>
|
||||
/// <param name="EncodedState">Signed state payload for callback validation.</param>
|
||||
/// <param name="Payload">Original payload values.</param>
|
||||
public sealed record GoogleOidcStartContext(
|
||||
string AuthorizationUrl,
|
||||
string EncodedState,
|
||||
GoogleOidcStatePayload Payload);
|
||||
|
||||
/// <summary>
|
||||
/// Signed OIDC callback state payload.
|
||||
/// </summary>
|
||||
/// <param name="State">Opaque callback state value.</param>
|
||||
/// <param name="Nonce">OIDC nonce value.</param>
|
||||
/// <param name="CodeVerifier">PKCE code verifier.</param>
|
||||
/// <param name="ReturnUrl">Validated caller return URL.</param>
|
||||
/// <param name="TenantId">Tenant value used for session start.</param>
|
||||
/// <param name="ExpiresAtUtc">Payload expiration time in UTC.</param>
|
||||
public sealed record GoogleOidcStatePayload(
|
||||
string State,
|
||||
string Nonce,
|
||||
string CodeVerifier,
|
||||
string ReturnUrl,
|
||||
string TenantId,
|
||||
DateTimeOffset ExpiresAtUtc);
|
||||
@ -0,0 +1,16 @@
|
||||
namespace Thalos.Bff.Application.Security.Oidc;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime options for Google OIDC flow orchestration in the BFF edge.
|
||||
/// </summary>
|
||||
public sealed record GoogleOidcOptions(
|
||||
string ClientId,
|
||||
string ClientSecret,
|
||||
string RedirectUri,
|
||||
string AuthorizationEndpoint,
|
||||
string TokenEndpoint,
|
||||
string Scope,
|
||||
string DefaultReturnUrl,
|
||||
string DefaultTenantId,
|
||||
IReadOnlyCollection<string> AllowedReturnHosts,
|
||||
TimeSpan StateLifetime);
|
||||
@ -2,11 +2,14 @@ using BuildingBlock.Identity.Contracts.Conventions;
|
||||
using BuildingBlock.Identity.Contracts.Requests;
|
||||
using Core.Blueprint.Common.DependencyInjection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Thalos.Bff.Application.Adapters;
|
||||
using Thalos.Bff.Application.DependencyInjection;
|
||||
using Thalos.Bff.Application.Handlers;
|
||||
using Thalos.Bff.Application.Security;
|
||||
using Thalos.Bff.Application.Security.Oidc;
|
||||
using Thalos.Bff.Contracts.Api;
|
||||
using Thalos.Bff.Rest.Adapters;
|
||||
using Thalos.Bff.Rest.Endpoints;
|
||||
@ -15,6 +18,7 @@ using Thalos.Service.Grpc;
|
||||
const string CorrelationHeaderName = "x-correlation-id";
|
||||
const string SessionAccessCookieName = "thalos_session";
|
||||
const string SessionRefreshCookieName = "thalos_refresh";
|
||||
const string OidcStateCookieName = "thalos_oidc_state";
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var edgeProtocol = builder.Configuration["ThalosBff:EdgeProtocol"] ?? "rest";
|
||||
@ -29,6 +33,35 @@ builder.Services.AddHealthChecks();
|
||||
builder.Services.AddBlueprintRuntimeCore();
|
||||
builder.Services.AddThalosBffApplicationRuntime();
|
||||
builder.Services.AddScoped<IThalosServiceClient, ThalosServiceGrpcClientAdapter>();
|
||||
builder.Services.AddHttpClient("GoogleOidcToken");
|
||||
builder.Services.AddSingleton(_ =>
|
||||
{
|
||||
var options = new GoogleOidcOptions(
|
||||
builder.Configuration["ThalosBff:Oidc:Google:ClientId"] ?? string.Empty,
|
||||
builder.Configuration["ThalosBff:Oidc:Google:ClientSecret"] ?? string.Empty,
|
||||
builder.Configuration["ThalosBff:Oidc:Google:RedirectUri"] ??
|
||||
"https://auth.dream-views.com/api/identity/oidc/google/callback",
|
||||
builder.Configuration["ThalosBff:Oidc:Google:AuthorizationEndpoint"] ??
|
||||
"https://accounts.google.com/o/oauth2/v2/auth",
|
||||
builder.Configuration["ThalosBff:Oidc:Google:TokenEndpoint"] ??
|
||||
"https://oauth2.googleapis.com/token",
|
||||
builder.Configuration["ThalosBff:Oidc:Google:Scope"] ?? "openid profile email",
|
||||
builder.Configuration["ThalosBff:Oidc:DefaultReturnUrl"] ?? "https://auth.dream-views.com/",
|
||||
builder.Configuration["ThalosBff:Oidc:DefaultTenantId"] ?? "demo-tenant",
|
||||
(builder.Configuration.GetSection("ThalosBff:Oidc:AllowedReturnHosts").Get<string[]>() ??
|
||||
["auth.dream-views.com", "furniture-display-demo.dream-views.com", "furniture-admin-demo.dream-views.com",
|
||||
"kitchen-ops-demo.dream-views.com", "waiter-floor-demo.dream-views.com", "customer-orders-demo.dream-views.com",
|
||||
"pos-transactions-demo.dream-views.com", "restaurant-admin-demo.dream-views.com", "localhost"])
|
||||
.Where(host => !string.IsNullOrWhiteSpace(host))
|
||||
.ToArray(),
|
||||
TimeSpan.FromMinutes(builder.Configuration.GetValue("ThalosBff:Oidc:StateLifetimeMinutes", 10)));
|
||||
|
||||
var stateSigningSecret = builder.Configuration["ThalosBff:Oidc:StateSigningSecret"] ??
|
||||
builder.Configuration["ThalosBff:SessionStateSigningSecret"] ??
|
||||
string.Empty;
|
||||
|
||||
return new GoogleOidcFlowService(options, stateSigningSecret);
|
||||
});
|
||||
builder.Services.AddGrpcClient<IdentityRuntime.IdentityRuntimeClient>(options =>
|
||||
{
|
||||
var serviceAddress = builder.Configuration["ThalosService:GrpcAddress"] ?? "http://localhost:5251";
|
||||
@ -46,6 +79,91 @@ app.Use(async (context, next) =>
|
||||
await next();
|
||||
});
|
||||
|
||||
app.MapGet($"{EndpointConventions.ApiPrefix}/oidc/google/start", (
|
||||
HttpContext context,
|
||||
GoogleOidcFlowService oidcFlowService,
|
||||
IConfiguration configuration) =>
|
||||
{
|
||||
var correlationId = ResolveCorrelationId(context);
|
||||
var returnUrl = context.Request.Query["returnUrl"].ToString();
|
||||
var tenantId = context.Request.Query["tenantId"].ToString();
|
||||
|
||||
GoogleOidcStartContext startContext;
|
||||
try
|
||||
{
|
||||
startContext = oidcFlowService.BuildStartContext(returnUrl, tenantId);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return ErrorResult(StatusCodes.Status500InternalServerError, "oidc_configuration_invalid", "OIDC configuration is incomplete.", correlationId);
|
||||
}
|
||||
|
||||
context.Response.Cookies.Append(
|
||||
OidcStateCookieName,
|
||||
startContext.EncodedState,
|
||||
CreateOidcStateCookieOptions(configuration));
|
||||
|
||||
return Results.Redirect(startContext.AuthorizationUrl);
|
||||
});
|
||||
|
||||
app.MapGet($"{EndpointConventions.ApiPrefix}/oidc/google/callback", async (
|
||||
HttpContext context,
|
||||
GoogleOidcFlowService oidcFlowService,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IThalosServiceClient serviceClient,
|
||||
IConfiguration configuration) =>
|
||||
{
|
||||
var correlationId = ResolveCorrelationId(context);
|
||||
var providerError = context.Request.Query["error"].ToString();
|
||||
if (!string.IsNullOrWhiteSpace(providerError))
|
||||
{
|
||||
return ErrorResult(StatusCodes.Status401Unauthorized, "oidc_provider_error", $"Provider rejected authentication: {providerError}.", correlationId);
|
||||
}
|
||||
|
||||
var callbackState = context.Request.Query["state"].ToString();
|
||||
var code = context.Request.Query["code"].ToString();
|
||||
|
||||
if (!context.Request.Cookies.TryGetValue(OidcStateCookieName, out var encodedState) ||
|
||||
!oidcFlowService.TryValidateCallbackState(encodedState, callbackState, out var payload))
|
||||
{
|
||||
return ErrorResult(StatusCodes.Status401Unauthorized, "oidc_state_invalid", "OIDC callback state is invalid.", correlationId);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
return ErrorResult(StatusCodes.Status401Unauthorized, "oidc_code_missing", "OIDC callback code is missing.", correlationId);
|
||||
}
|
||||
|
||||
var tokenResponse = await ExchangeGoogleCodeForTokenAsync(
|
||||
code,
|
||||
payload.CodeVerifier,
|
||||
oidcFlowService,
|
||||
httpClientFactory,
|
||||
correlationId,
|
||||
context.RequestAborted);
|
||||
|
||||
if (tokenResponse is null || string.IsNullOrWhiteSpace(tokenResponse.IdToken))
|
||||
{
|
||||
return ErrorResult(StatusCodes.Status401Unauthorized, "oidc_exchange_failed", "Unable to exchange provider code.", correlationId);
|
||||
}
|
||||
|
||||
var serviceRequest = new IssueIdentityTokenRequest(
|
||||
string.Empty,
|
||||
payload.TenantId,
|
||||
IdentityAuthProvider.Google,
|
||||
tokenResponse.IdToken);
|
||||
var sessionTokens = await serviceClient.StartSessionAsync(serviceRequest, correlationId);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sessionTokens.AccessToken) || string.IsNullOrWhiteSpace(sessionTokens.RefreshToken))
|
||||
{
|
||||
return ErrorResult(StatusCodes.Status401Unauthorized, "session_login_failed", "Unable to issue session.", correlationId);
|
||||
}
|
||||
|
||||
WriteSessionCookies(context, sessionTokens, configuration);
|
||||
context.Response.Cookies.Delete(OidcStateCookieName, CreateOidcStateCookieOptions(configuration));
|
||||
return Results.Redirect(payload.ReturnUrl);
|
||||
});
|
||||
|
||||
app.MapPost($"{EndpointConventions.ApiPrefix}/session/login", async (
|
||||
SessionLoginApiRequest request,
|
||||
HttpContext context,
|
||||
@ -303,18 +421,65 @@ void DeleteSessionCookies(HttpContext context, IConfiguration configuration)
|
||||
context.Response.Cookies.Delete(SessionRefreshCookieName, options);
|
||||
}
|
||||
|
||||
static CookieOptions CreateCookieOptions(bool secure, int expiresInSeconds)
|
||||
CookieOptions CreateCookieOptions(bool secure, int expiresInSeconds)
|
||||
{
|
||||
var domain = builder.Configuration["ThalosBff:SessionCookieDomain"];
|
||||
return new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = secure,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Path = "/",
|
||||
Domain = string.IsNullOrWhiteSpace(domain) ? null : domain,
|
||||
MaxAge = TimeSpan.FromSeconds(Math.Max(0, expiresInSeconds))
|
||||
};
|
||||
}
|
||||
|
||||
async Task<GoogleOidcTokenExchangeResponse?> ExchangeGoogleCodeForTokenAsync(
|
||||
string code,
|
||||
string codeVerifier,
|
||||
GoogleOidcFlowService oidcFlowService,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var formValues = oidcFlowService.BuildTokenExchangeForm(code, codeVerifier);
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
oidcFlowService.GetTokenEndpoint())
|
||||
{
|
||||
Content = new FormUrlEncodedContent(formValues)
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation(CorrelationHeaderName, correlationId);
|
||||
|
||||
using var response = await httpClientFactory
|
||||
.CreateClient("GoogleOidcToken")
|
||||
.SendAsync(request, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
return await JsonSerializer.DeserializeAsync<GoogleOidcTokenExchangeResponse>(stream, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
CookieOptions CreateOidcStateCookieOptions(IConfiguration configuration)
|
||||
{
|
||||
var secureCookie = configuration.GetValue("ThalosBff:SessionCookieSecure", false);
|
||||
var domain = configuration["ThalosBff:SessionCookieDomain"];
|
||||
return new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = secureCookie,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Path = "/",
|
||||
Domain = string.IsNullOrWhiteSpace(domain) ? null : domain,
|
||||
MaxAge = TimeSpan.FromMinutes(configuration.GetValue("ThalosBff:Oidc:StateLifetimeMinutes", 10))
|
||||
};
|
||||
}
|
||||
|
||||
static bool TryParseSessionProfile(string accessToken, out SessionMeApiResponse response)
|
||||
{
|
||||
response = new SessionMeApiResponse(false, string.Empty, string.Empty, IdentityAuthProvider.InternalJwt);
|
||||
@ -356,3 +521,8 @@ static bool TryParseSessionProfile(string accessToken, out SessionMeApiResponse
|
||||
response = new SessionMeApiResponse(true, subjectId, tenantId, provider);
|
||||
return true;
|
||||
}
|
||||
|
||||
sealed record GoogleOidcTokenExchangeResponse(
|
||||
[property: JsonPropertyName("access_token")] string AccessToken,
|
||||
[property: JsonPropertyName("id_token")] string IdToken,
|
||||
[property: JsonPropertyName("expires_in")] int ExpiresIn);
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
using Thalos.Bff.Application.Security.Oidc;
|
||||
|
||||
namespace Thalos.Bff.Application.UnitTests;
|
||||
|
||||
public class GoogleOidcFlowServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildStartContext_WhenReturnUrlAllowed_PreservesCallerReturnUrl()
|
||||
{
|
||||
var service = new GoogleOidcFlowService(CreateOptions(), "state-signing-secret");
|
||||
|
||||
var context = service.BuildStartContext("https://furniture-display-demo.dream-views.com/dashboard", "tenant-1");
|
||||
|
||||
Assert.Equal("https://furniture-display-demo.dream-views.com/dashboard", context.Payload.ReturnUrl);
|
||||
Assert.Equal("tenant-1", context.Payload.TenantId);
|
||||
Assert.Contains("code_challenge_method=S256", context.AuthorizationUrl);
|
||||
Assert.Contains("state=", context.AuthorizationUrl);
|
||||
Assert.Contains("nonce=", context.AuthorizationUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildStartContext_WhenReturnUrlNotAllowed_UsesDefaultReturnUrl()
|
||||
{
|
||||
var service = new GoogleOidcFlowService(CreateOptions(), "state-signing-secret");
|
||||
|
||||
var context = service.BuildStartContext("https://malicious.example.com/redirect", "tenant-1");
|
||||
|
||||
Assert.Equal("https://auth.dream-views.com/", context.Payload.ReturnUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidateCallbackState_WhenStateMatchesAndNotExpired_ReturnsTrue()
|
||||
{
|
||||
var service = new GoogleOidcFlowService(CreateOptions(), "state-signing-secret");
|
||||
var start = service.BuildStartContext("https://auth.dream-views.com/", "tenant-1");
|
||||
|
||||
var ok = service.TryValidateCallbackState(start.EncodedState, start.Payload.State, out var payload);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(start.Payload.CodeVerifier, payload.CodeVerifier);
|
||||
Assert.Equal(start.Payload.TenantId, payload.TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidateCallbackState_WhenStateTampered_ReturnsFalse()
|
||||
{
|
||||
var service = new GoogleOidcFlowService(CreateOptions(), "state-signing-secret");
|
||||
var start = service.BuildStartContext("https://auth.dream-views.com/", "tenant-1");
|
||||
|
||||
var ok = service.TryValidateCallbackState(start.EncodedState, $"{start.Payload.State}-tamper", out _);
|
||||
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
private static GoogleOidcOptions CreateOptions()
|
||||
{
|
||||
return new GoogleOidcOptions(
|
||||
"client-id-1",
|
||||
"client-secret-1",
|
||||
"https://auth.dream-views.com/api/identity/oidc/google/callback",
|
||||
"https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"https://oauth2.googleapis.com/token",
|
||||
"openid profile email",
|
||||
"https://auth.dream-views.com/",
|
||||
"demo-tenant",
|
||||
["auth.dream-views.com", "furniture-display-demo.dream-views.com"],
|
||||
TimeSpan.FromMinutes(10));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user