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/refresh`
|
||||||
- `POST /api/identity/session/logout`
|
- `POST /api/identity/session/logout`
|
||||||
- `GET /api/identity/session/me`
|
- `GET /api/identity/session/me`
|
||||||
|
- Canonical OIDC endpoints:
|
||||||
|
- `GET /api/identity/oidc/google/start`
|
||||||
|
- `GET /api/identity/oidc/google/callback`
|
||||||
- Compatibility endpoint:
|
- Compatibility endpoint:
|
||||||
- `POST /api/identity/token`
|
- `POST /api/identity/token`
|
||||||
- `POST /api/identity/login`
|
- `POST /api/identity/login`
|
||||||
@ -22,7 +25,8 @@
|
|||||||
|
|
||||||
- Endpoint handlers perform edge validation and permission checks.
|
- Endpoint handlers perform edge validation and permission checks.
|
||||||
- Session login and refresh call canonical thalos-service session gRPC operations.
|
- 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.
|
- Token issuance and policy evaluation contracts remain available for compatibility calls.
|
||||||
- Business orchestration remains in thalos-service.
|
- Business orchestration remains in thalos-service.
|
||||||
- Identity abstractions remain owned by Thalos repositories.
|
- 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
|
- Correlation/tracing propagation
|
||||||
- Single active edge protocol policy enforcement (`rest`)
|
- Single active edge protocol policy enforcement (`rest`)
|
||||||
- Provider metadata propagation (`InternalJwt`, `AzureAd`, `Google`)
|
- 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
|
## Prohibited
|
||||||
- Direct DAL access
|
- Direct DAL access
|
||||||
- Identity policy decision ownership
|
- Identity policy decision ownership
|
||||||
- Identity persistence concerns
|
- 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
|
## Local Run
|
||||||
|
|
||||||
```bash
|
```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
|
## 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.
|
- 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.
|
- 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 BuildingBlock.Identity.Contracts.Requests;
|
||||||
using Core.Blueprint.Common.DependencyInjection;
|
using Core.Blueprint.Common.DependencyInjection;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.Extensions.Primitives;
|
using Microsoft.Extensions.Primitives;
|
||||||
using Thalos.Bff.Application.Adapters;
|
using Thalos.Bff.Application.Adapters;
|
||||||
using Thalos.Bff.Application.DependencyInjection;
|
using Thalos.Bff.Application.DependencyInjection;
|
||||||
using Thalos.Bff.Application.Handlers;
|
using Thalos.Bff.Application.Handlers;
|
||||||
using Thalos.Bff.Application.Security;
|
using Thalos.Bff.Application.Security;
|
||||||
|
using Thalos.Bff.Application.Security.Oidc;
|
||||||
using Thalos.Bff.Contracts.Api;
|
using Thalos.Bff.Contracts.Api;
|
||||||
using Thalos.Bff.Rest.Adapters;
|
using Thalos.Bff.Rest.Adapters;
|
||||||
using Thalos.Bff.Rest.Endpoints;
|
using Thalos.Bff.Rest.Endpoints;
|
||||||
@ -15,6 +18,7 @@ using Thalos.Service.Grpc;
|
|||||||
const string CorrelationHeaderName = "x-correlation-id";
|
const string CorrelationHeaderName = "x-correlation-id";
|
||||||
const string SessionAccessCookieName = "thalos_session";
|
const string SessionAccessCookieName = "thalos_session";
|
||||||
const string SessionRefreshCookieName = "thalos_refresh";
|
const string SessionRefreshCookieName = "thalos_refresh";
|
||||||
|
const string OidcStateCookieName = "thalos_oidc_state";
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
var edgeProtocol = builder.Configuration["ThalosBff:EdgeProtocol"] ?? "rest";
|
var edgeProtocol = builder.Configuration["ThalosBff:EdgeProtocol"] ?? "rest";
|
||||||
@ -29,6 +33,35 @@ builder.Services.AddHealthChecks();
|
|||||||
builder.Services.AddBlueprintRuntimeCore();
|
builder.Services.AddBlueprintRuntimeCore();
|
||||||
builder.Services.AddThalosBffApplicationRuntime();
|
builder.Services.AddThalosBffApplicationRuntime();
|
||||||
builder.Services.AddScoped<IThalosServiceClient, ThalosServiceGrpcClientAdapter>();
|
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 =>
|
builder.Services.AddGrpcClient<IdentityRuntime.IdentityRuntimeClient>(options =>
|
||||||
{
|
{
|
||||||
var serviceAddress = builder.Configuration["ThalosService:GrpcAddress"] ?? "http://localhost:5251";
|
var serviceAddress = builder.Configuration["ThalosService:GrpcAddress"] ?? "http://localhost:5251";
|
||||||
@ -46,6 +79,91 @@ app.Use(async (context, next) =>
|
|||||||
await 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 (
|
app.MapPost($"{EndpointConventions.ApiPrefix}/session/login", async (
|
||||||
SessionLoginApiRequest request,
|
SessionLoginApiRequest request,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
@ -303,18 +421,65 @@ void DeleteSessionCookies(HttpContext context, IConfiguration configuration)
|
|||||||
context.Response.Cookies.Delete(SessionRefreshCookieName, options);
|
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
|
return new CookieOptions
|
||||||
{
|
{
|
||||||
HttpOnly = true,
|
HttpOnly = true,
|
||||||
Secure = secure,
|
Secure = secure,
|
||||||
SameSite = SameSiteMode.Lax,
|
SameSite = SameSiteMode.Lax,
|
||||||
Path = "/",
|
Path = "/",
|
||||||
|
Domain = string.IsNullOrWhiteSpace(domain) ? null : domain,
|
||||||
MaxAge = TimeSpan.FromSeconds(Math.Max(0, expiresInSeconds))
|
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)
|
static bool TryParseSessionProfile(string accessToken, out SessionMeApiResponse response)
|
||||||
{
|
{
|
||||||
response = new SessionMeApiResponse(false, string.Empty, string.Empty, IdentityAuthProvider.InternalJwt);
|
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);
|
response = new SessionMeApiResponse(true, subjectId, tenantId, provider);
|
||||||
return true;
|
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