diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0a99aa3..5939dfd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,7 @@ env: DOTNET_VERSION: "10.0.x" DOTNET_CACHE: "dotnet-cache-${{ github.sha }}" FRONTEND_CACHE: "frontend-cache-${{ github.sha }}" + NUGET_OUTPUT: ./Artifacts/NuGet on: workflow_dispatch: @@ -30,6 +31,7 @@ on: permissions: contents: write packages: write + id-token: write jobs: release: @@ -155,3 +157,34 @@ jobs: tags: ${{ steps.docker-tags.outputs.tags }} build-args: | VERSION=${{ needs.release.outputs.version }} + + publish-nuget: + if: needs.release.outputs.publish == 'true' + runs-on: ubuntu-latest + needs: [release] + permissions: + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Remove any existing artifacts + run: rm -rf ${{ env.NUGET_OUTPUT }} + + - name: Pack Aspire hosting package + run: dotnet pack Source/Aspire/Aspire.csproj --configuration Release -o ${{ env.NUGET_OUTPUT }} -p:PackageVersion=${{ needs.release.outputs.version }} + + - name: NuGet login (OIDC → temp API key) + uses: NuGet/login@v1 + id: login + with: + user: ${{ secrets.NUGET_USER }} + + - name: Push NuGet package + run: dotnet nuget push --skip-duplicate '${{ env.NUGET_OUTPUT }}/*.nupkg' --timeout 900 --api-key ${{ steps.login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json diff --git a/AuthProxy.slnx b/AuthProxy.slnx index 5000dfa..222ebaf 100644 --- a/AuthProxy.slnx +++ b/AuthProxy.slnx @@ -1,6 +1,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 73e8e70..36e9e8e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,6 +7,7 @@ + diff --git a/Documentation/aspire/index.md b/Documentation/aspire/index.md new file mode 100644 index 0000000..80c88bc --- /dev/null +++ b/Documentation/aspire/index.md @@ -0,0 +1,260 @@ +# Aspire Hosting Integration + +The `Cratis.AuthProxy.Aspire` NuGet package adds first-class .NET Aspire support for AuthProxy. +Instead of configuring environment variables by hand, you wire up authentication, tenancy, and +service routing with a concise fluent API in your `AppHost`. + +## Installation + +```bash +dotnet add package Cratis.AuthProxy.Aspire +``` + +## Adding AuthProxy as a container resource + +This is the typical path for external consumers who run AuthProxy from Docker Hub: + +```csharp +var authproxy = builder.AddAuthProxy("authproxy", tag: "latest") + .WithHttpEndpoint(port: 8080) + .WithBackend("main", apiResource) + .WithFrontend("main", webResource) + .WithOidcProvider( + "Microsoft", + OidcProviderType.Microsoft, + authority: "https://login.microsoftonline.com//v2.0", + clientId: "", + clientSecret: "") + .WithHostTenantResolution(); +``` + +`AddAuthProxy` creates an `AuthProxyResource` backed by the `cratis/authproxy` Docker Hub image. +Pin `tag` to a specific release in production environments — the default `"latest"` is convenient +for local development. + +## Adding AuthProxy as a project resource + +When working inside the AuthProxy repository itself (or in a monorepo that includes AuthProxy +source), use `AddProject` with the same extension methods: + +```csharp +var authproxy = builder.AddProject("authproxy") + .WithBackend("main", apiResource) + .WithFrontend("main", webResource); +``` + +All `With*` methods work on any `IResourceBuilder where T : IResourceWithEnvironment`, +so you can mix container and project resources freely. + +--- + +## Services + +Use `WithBackend` and `WithFrontend` to register the resources that AuthProxy should proxy: + +```csharp +authproxy + .WithBackend("main", apiResource) + .WithFrontend("main", webResource); +``` + +Both methods accept an optional `endpointName` parameter (defaults to `"http"`) that selects +which endpoint from the target resource to forward to. + +### Identity details resolution + +For each service with a backend, AuthProxy calls `GET {baseUrl}/.cratis/me` after authentication +to enrich the identity cookie. This behaviour is on by default. To disable it for a specific +service, pass `resolveIdentityDetails: false` to `WithBackend`: + +```csharp +authproxy + .WithBackend("reporting", reportingApi, resolveIdentityDetails: false) + .WithFrontend("reporting", reportingWeb); +``` + +See [Services](../configuration/services.md) for the underlying configuration model. + +--- + +## Authentication + +### OIDC providers + +```csharp +authproxy.WithOidcProvider( + name: "Contoso AD", + type: OidcProviderType.Microsoft, + authority: "https://login.microsoftonline.com//v2.0", + clientId: "", + clientSecret: "", + scopes: ["api://my-api/.default"]); +``` + +Call `WithOidcProvider` once per provider. Multiple calls produce a provider-selection page. + +The `OidcProviderType` enum contains well-known provider brands: + +| Value | Description | +|-------|-------------| +| `Custom` | Generic / unknown provider. | +| `Microsoft` | Microsoft Identity Platform (Azure AD / Entra ID). | +| `Google` | Google Identity. | +| `GitHub` | GitHub OAuth / OIDC. | +| `Apple` | Apple Sign-In. | + +### OAuth 2.0 (non-OIDC) providers + +For providers that do not expose an OIDC discovery document, use `WithOAuthProvider`: + +```csharp +authproxy.WithOAuthProvider( + name: "GitHub", + type: OidcProviderType.GitHub, + authorizationEndpoint: "https://github.com/login/oauth/authorize", + tokenEndpoint: "https://github.com/login/oauth/access_token", + userInformationEndpoint: "https://api.github.com/user", + clientId: "", + clientSecret: "", + scopes: ["user:email"], + claimMappings: new Dictionary + { + ["sub"] = "id", + ["name"] = "login", + ["email"] = "email" + }); +``` + +See [Authentication](../configuration/authentication.md) for the full configuration reference. + +--- + +## Tenant resolution + +Add one or more resolution strategies. They run in order until a tenant is matched: + +| Method | Strategy | +|--------|----------| +| `WithHostTenantResolution()` | Matches the request host against configured tenant domains. | +| `WithSubHostTenantResolution()` | Derives the tenant from the first subdomain (e.g. `acme.example.com` → `acme`). | +| `WithClaimTenantResolution(claimType?)` | Reads a claim from the authenticated user. | +| `WithRouteTenantResolution(pattern)` | Extracts a source identifier from the request path by regex. | +| `WithSpecifiedTenantResolution(tenantId)` | Pins all requests to one fixed tenant (single-tenant deployments). | +| `WithDefaultTenantResolution(tenantId)` | Fallback when no other strategy resolves a tenant. | +| `WithSelectionTenantResolution()` | Reads the tenant from the cookie set by the tenant-selection page. | + +```csharp +authproxy + .WithSubHostTenantResolution() + .WithDefaultTenantResolution("lobby"); +``` + +See [Tenancy](../configuration/tenancy.md) for detailed strategy documentation. + +### Tenant verification + +After resolution, AuthProxy can confirm the tenant exists by calling your back-end. You can +pass a raw URL template or reference an Aspire service resource directly: + +```csharp +// Raw URL template +authproxy.WithTenantVerification("https://platform.example.com/api/tenants/{tenantId}"); + +// Aspire resource reference — endpoint is resolved automatically +authproxy.WithTenantVerification(platformApi, "/api/tenants/{tenantId}"); +``` + +AuthProxy issues a `GET` to the resolved URL. A `200` response lets the request proceed; `404` or +any error serves the `tenant-not-found.html` page. + +--- + +## Tenant selection + +When users can be members of more than one tenant, the `Selection` strategy presents a +tenant-selection page after login. You can pass a raw URL or reference an Aspire service resource: + +```csharp +// Raw URL +authproxy.WithSelectionTenantResolution( + tenantsEndpoint: "https://platform.example.com/api/tenants/selectable"); + +// Aspire resource reference — endpoint is resolved automatically +authproxy.WithSelectionTenantResolution(platformApi, "/api/tenants/selectable"); +``` + +AuthProxy calls the endpoint after login and, if more than one tenant is returned, serves the +built-in `select-tenant.html` page. If only one tenant is returned the selection page is +skipped and the user is redirected immediately. + +The endpoint must return a JSON array of `{ "id": "...", "name": "..." }` objects. + +See [Tenant Selection Page](../configuration/tenant-selection.md) for details on building a +custom selection page and the full flow. + +--- + +## Invites and lobby + +### Core invite configuration + +Configure the invite system with the RSA public key and exchange endpoint. You can pass a raw URL +or reference an Aspire service resource for the exchange endpoint: + +```csharp +// Raw URL +authproxy.WithInvite( + publicKeyPem: File.ReadAllText("invite-public-key.pem"), + exchangeUrl: "https://studio.example.com/internal/invites/exchange", + issuer: "https://studio.example.com", + audience: "authproxy", + tenantClaim: "tenant_id", + subjectAlreadyExistsUrl: "https://app.example.com/errors/account-already-exists"); + +// Aspire resource reference — exchange endpoint URL is resolved automatically +authproxy.WithInvite( + publicKeyPem: File.ReadAllText("invite-public-key.pem"), + exchangeServiceResource: studioApi, + exchangeRoute: "/internal/invites/exchange", + issuer: "https://studio.example.com", + tenantClaim: "tenant_id"); +``` + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `publicKeyPem` | ✓ | PEM-encoded RSA public key to verify invite token signatures. | +| `exchangeUrl` | ✓ | Endpoint called after login to exchange the invite token. | +| `issuer` | – | Expected `iss` claim. Omit to skip issuer validation. | +| `audience` | – | Expected `aud` claim. Omit to skip audience validation. | +| `tenantClaim` | – | Claim that carries the tenant ID for tenant-issued invite detection. | +| `subjectAlreadyExistsUrl` | – | Redirect URL when the exchange endpoint returns HTTP 409. Omit to serve the built-in page. | + +### Claim forwarding + +To propagate invite-token claims into the principal sent to `/.cratis/me` endpoints, call +`WithInviteClaimForwarding` once per claim: + +```csharp +authproxy + .WithInviteClaimForwarding("organization_id", toClaimType: "organization") + .WithInviteClaimForwarding("invited_by"); +``` + +When `toClaimType` is omitted the original claim type is preserved. + +### Lobby + +The lobby is the service users are redirected to when no tenant can be resolved — typically +an onboarding application. At minimum, configure the lobby frontend: + +```csharp +authproxy + .WithLobbyFrontend(lobbyResource) + .WithLobbyBackend(lobbyApiResource); // optional +``` + +`WithLobbyFrontend` and `WithLobbyBackend` both accept an optional `endpointName` parameter +(defaults to `"http"`). + +See [Invites & Lobby](../configuration/invites.md) for the full invite flow walkthrough. + diff --git a/Documentation/aspire/toc.yml b/Documentation/aspire/toc.yml new file mode 100644 index 0000000..1b041cd --- /dev/null +++ b/Documentation/aspire/toc.yml @@ -0,0 +1,2 @@ +- name: Aspire Hosting + href: index.md diff --git a/Documentation/toc.yml b/Documentation/toc.yml index 471f13e..9d78419 100644 --- a/Documentation/toc.yml +++ b/Documentation/toc.yml @@ -2,3 +2,5 @@ href: index.md - name: Configuration href: configuration/toc.yml +- name: Aspire Hosting + href: aspire/toc.yml diff --git a/Source/Aspire/Aspire.csproj b/Source/Aspire/Aspire.csproj new file mode 100644 index 0000000..79732e9 --- /dev/null +++ b/Source/Aspire/Aspire.csproj @@ -0,0 +1,29 @@ + + + + Library + net10.0 + Cratis.AuthProxy.Aspire + true + $(NoWarn);CS1591 + + + Cratis.AuthProxy.Aspire + Cratis AuthProxy — Aspire Hosting + Aspire hosting integration for Cratis AuthProxy. Provides fluent extension methods to add and configure AuthProxy (authentication, tenancy, backend/frontend routing) in a .NET Aspire AppHost — either as a container resource or layered on an existing ProjectResource. + Cratis + Copyright Cratis + MIT + https://github.com/cratis/authproxy + https://github.com/cratis/authproxy + git + true + aspire;authproxy;authentication;oidc;tenancy;cratis + true + + + + + + + diff --git a/Source/Aspire/AuthProxyConfigAnnotation.cs b/Source/Aspire/AuthProxyConfigAnnotation.cs new file mode 100644 index 0000000..6b29b95 --- /dev/null +++ b/Source/Aspire/AuthProxyConfigAnnotation.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Aspire; + +/// +/// Annotation that tracks per-resource state used when building array-based AuthProxy configuration +/// (e.g. OIDC/OAuth providers, tenant resolution strategies). +/// +sealed class AuthProxyConfigAnnotation : IResourceAnnotation +{ + /// Gets or sets the number of OIDC providers that have been registered. + public int OidcProviderCount { get; set; } + + /// Gets or sets the number of OAuth providers that have been registered. + public int OAuthProviderCount { get; set; } + + /// Gets or sets the number of tenant resolution strategies that have been registered. + public int TenantResolutionCount { get; set; } + + /// Gets or sets the number of invite claim-forwarding entries that have been registered. + public int InviteClaimForwardingCount { get; set; } +} diff --git a/Source/Aspire/AuthProxyExtensions.cs b/Source/Aspire/AuthProxyExtensions.cs new file mode 100644 index 0000000..fbfa903 --- /dev/null +++ b/Source/Aspire/AuthProxyExtensions.cs @@ -0,0 +1,658 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Aspire; + +/// +/// Extension methods for adding and configuring in an Aspire application model. +/// +public static class AuthProxyExtensions +{ + const string ConfigPrefix = "Cratis__AuthProxy"; + + /// + /// Adds an AuthProxy container resource to the application model. + /// + /// The . + /// The resource name (e.g. "authproxy"). + /// + /// Optional Docker image tag. Defaults to (latest). + /// Pin this to a specific release in production (e.g. "1.2.3"). + /// + /// An for the . + public static IResourceBuilder AddAuthProxy( + this IDistributedApplicationBuilder builder, + string name, + string? tag = null) => + builder + .AddResource(new AuthProxyResource(name)) + .WithImage(AuthProxyResource.ContainerImageName, tag ?? AuthProxyResource.ContainerImageTag); + + /// + /// Registers a backend (API) endpoint for a named service in AuthProxy. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// + /// The service key used in the AuthProxy Services configuration (e.g. "main"). + /// + /// The Aspire resource that exposes the backend. + /// The endpoint name to use. Defaults to "http". + /// + /// Whether AuthProxy should call GET {baseUrl}/.cratis/me on this backend to enrich + /// the identity cookie after authentication. Defaults to (AuthProxy uses + /// its own default — when a backend URL is present). + /// Set to explicitly to opt this service out of identity enrichment. + /// + /// The same for chaining. + public static IResourceBuilder WithBackend( + this IResourceBuilder builder, + string serviceName, + IResourceBuilder serviceResource, + string endpointName = "http", + bool? resolveIdentityDetails = null) + where T : IResourceWithEnvironment + { + var endpoint = serviceResource.GetEndpoint(endpointName); + builder.WithEnvironment(context => + context.EnvironmentVariables[$"{ConfigPrefix}__Services__{serviceName}__Backend__BaseUrl"] = + ReferenceExpression.Create($"{endpoint}/")); + + if (resolveIdentityDetails.HasValue) + { + builder.WithEnvironment( + $"{ConfigPrefix}__Services__{serviceName}__ResolveIdentityDetails", + resolveIdentityDetails.Value.ToString()); + } + + return builder; + } + + /// + /// Registers a frontend (SPA / static-assets) endpoint for a named service in AuthProxy. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// + /// The service key used in the AuthProxy Services configuration (e.g. "main"). + /// + /// The Aspire resource that exposes the frontend. + /// The endpoint name to use. Defaults to "http". + /// The same for chaining. + public static IResourceBuilder WithFrontend( + this IResourceBuilder builder, + string serviceName, + IResourceBuilder serviceResource, + string endpointName = "http") + where T : IResourceWithEnvironment + { + var endpoint = serviceResource.GetEndpoint(endpointName); + return builder.WithEnvironment(context => + context.EnvironmentVariables[$"{ConfigPrefix}__Services__{serviceName}__Frontend__BaseUrl"] = + ReferenceExpression.Create($"{endpoint}/")); + } + + /// + /// Adds an OIDC provider to the AuthProxy authentication configuration. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// The display name shown on the login page (e.g. "Contoso AD"). + /// The provider brand / type. Used by the login UI to choose the correct logo. + /// The OIDC authority / issuer URL. + /// The OAuth client ID. + /// The OAuth client secret. + /// + /// Optional extra OAuth scopes to request in addition to openid profile email. + /// + /// The same for chaining. + public static IResourceBuilder WithOidcProvider( + this IResourceBuilder builder, + string name, + OidcProviderType type, + string authority, + string clientId, + string clientSecret, + IEnumerable? scopes = null) + where T : IResourceWithEnvironment + { + var annotation = GetOrCreateAnnotation(builder.Resource); + var idx = annotation.OidcProviderCount++; + var prefix = $"{ConfigPrefix}__Authentication__OidcProviders__{idx}"; + + builder + .WithEnvironment($"{prefix}__Name", name) + .WithEnvironment($"{prefix}__Type", type.ToString()) + .WithEnvironment($"{prefix}__Authority", authority) + .WithEnvironment($"{prefix}__ClientId", clientId) + .WithEnvironment($"{prefix}__ClientSecret", clientSecret); + + var scopeList = scopes?.ToList() ?? []; + for (var i = 0; i < scopeList.Count; i++) + { + builder.WithEnvironment($"{prefix}__Scopes__{i}", scopeList[i]); + } + + return builder; + } + + /// + /// Adds a regular OAuth 2.0 (non-OIDC) provider such as GitHub to the AuthProxy authentication configuration. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// The display name shown on the login page (e.g. "GitHub"). + /// The provider brand / type. + /// The OAuth 2.0 authorization endpoint URL. + /// The OAuth 2.0 token endpoint URL. + /// The user-information (profile) API endpoint URL. + /// The OAuth client ID. + /// The OAuth client secret. + /// Optional extra OAuth scopes to request. + /// + /// Optional claim mappings from the user-info JSON response. + /// Key = claim type; value = JSON field name in the user-info response. + /// + /// The same for chaining. + public static IResourceBuilder WithOAuthProvider( + this IResourceBuilder builder, + string name, + OidcProviderType type, + string authorizationEndpoint, + string tokenEndpoint, + string userInformationEndpoint, + string clientId, + string clientSecret, + IEnumerable? scopes = null, + IDictionary? claimMappings = null) + where T : IResourceWithEnvironment + { + var annotation = GetOrCreateAnnotation(builder.Resource); + var idx = annotation.OAuthProviderCount++; + var prefix = $"{ConfigPrefix}__Authentication__OAuthProviders__{idx}"; + + builder + .WithEnvironment($"{prefix}__Name", name) + .WithEnvironment($"{prefix}__Type", type.ToString()) + .WithEnvironment($"{prefix}__AuthorizationEndpoint", authorizationEndpoint) + .WithEnvironment($"{prefix}__TokenEndpoint", tokenEndpoint) + .WithEnvironment($"{prefix}__UserInformationEndpoint", userInformationEndpoint) + .WithEnvironment($"{prefix}__ClientId", clientId) + .WithEnvironment($"{prefix}__ClientSecret", clientSecret); + + var scopeList = scopes?.ToList() ?? []; + for (var i = 0; i < scopeList.Count; i++) + { + builder.WithEnvironment($"{prefix}__Scopes__{i}", scopeList[i]); + } + + if (claimMappings is not null) + { + foreach (var (claimType, jsonField) in claimMappings) + { + builder.WithEnvironment($"{prefix}__ClaimMappings__{claimType}", jsonField); + } + } + + return builder; + } + + /// + /// Adds a host-name-based tenant resolution strategy to AuthProxy. + /// The resolved host is matched against the Domains list of each configured tenant. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// The same for chaining. + public static IResourceBuilder WithHostTenantResolution(this IResourceBuilder builder) + where T : IResourceWithEnvironment => + AddTenantResolution(builder, "Host"); + + /// + /// Adds a sub-host-based tenant resolution strategy to AuthProxy. + /// The tenant ID is derived from the first subdomain label of the request host by convention + /// (e.g. acme.example.comacme). + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// The same for chaining. + public static IResourceBuilder WithSubHostTenantResolution(this IResourceBuilder builder) + where T : IResourceWithEnvironment => + AddTenantResolution(builder, "SubHost"); + + /// + /// Adds a claim-based tenant resolution strategy to AuthProxy. + /// The tenant source identifier is read from the specified claim in the authenticated principal. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// + /// The claim type to read. + /// When the AuthProxy default (the Microsoft standard tenant claim) is used. + /// + /// The same for chaining. + public static IResourceBuilder WithClaimTenantResolution( + this IResourceBuilder builder, + string? claimType = null) + where T : IResourceWithEnvironment + { + var annotation = GetOrCreateAnnotation(builder.Resource); + var idx = annotation.TenantResolutionCount++; + var prefix = $"{ConfigPrefix}__TenantResolutions__{idx}"; + + builder.WithEnvironment($"{prefix}__Strategy", "Claim"); + if (!string.IsNullOrEmpty(claimType)) + { + builder.WithEnvironment($"{prefix}__Options__ClaimType", claimType); + } + + return builder; + } + + /// + /// Adds a route-segment-based tenant resolution strategy to AuthProxy. + /// The tenant source identifier is extracted from the request path using a named-group regular expression. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// + /// A regular expression with a named capture group whose value becomes the tenant source identifier. + /// Example: ^/(?<tenant>[^/]+)/. + /// + /// The same for chaining. + public static IResourceBuilder WithRouteTenantResolution( + this IResourceBuilder builder, + string pattern) + where T : IResourceWithEnvironment + { + var annotation = GetOrCreateAnnotation(builder.Resource); + var idx = annotation.TenantResolutionCount++; + var prefix = $"{ConfigPrefix}__TenantResolutions__{idx}"; + + return builder + .WithEnvironment($"{prefix}__Strategy", "Route") + .WithEnvironment($"{prefix}__Options__Pattern", pattern); + } + + /// + /// Adds a fixed-tenant resolution strategy to AuthProxy. + /// Every request is resolved to the same pre-configured tenant ID (single-tenant deployments). + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// The tenant ID that every request should resolve to. + /// The same for chaining. + public static IResourceBuilder WithSpecifiedTenantResolution( + this IResourceBuilder builder, + string tenantId) + where T : IResourceWithEnvironment + { + var annotation = GetOrCreateAnnotation(builder.Resource); + var idx = annotation.TenantResolutionCount++; + var prefix = $"{ConfigPrefix}__TenantResolutions__{idx}"; + + return builder + .WithEnvironment($"{prefix}__Strategy", "Specified") + .WithEnvironment($"{prefix}__Options__TenantId", tenantId); + } + + /// + /// Adds a default-tenant fallback resolution strategy to AuthProxy. + /// Resolves to the configured default tenant ID when no other strategy matches. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// The fallback tenant ID. + /// The same for chaining. + public static IResourceBuilder WithDefaultTenantResolution( + this IResourceBuilder builder, + string tenantId) + where T : IResourceWithEnvironment + { + var annotation = GetOrCreateAnnotation(builder.Resource); + var idx = annotation.TenantResolutionCount++; + var prefix = $"{ConfigPrefix}__TenantResolutions__{idx}"; + + return builder + .WithEnvironment($"{prefix}__Strategy", "Default") + .WithEnvironment($"{prefix}__Options__TenantId", tenantId); + } + + /// + /// Adds a cookie-selection-based tenant resolution strategy to AuthProxy. + /// The tenant ID is read from the cookie set by the AuthProxy tenant-selection page. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// + /// Absolute URL of the endpoint that returns selectable tenants for the current authenticated user. + /// Expected response shape is an array of { "id": "...", "name": "..." } objects. + /// When the endpoint is omitted and must be supplied via other configuration. + /// + /// The same for chaining. + public static IResourceBuilder WithSelectionTenantResolution( + this IResourceBuilder builder, + string? tenantsEndpoint = null) + where T : IResourceWithEnvironment + { + var annotation = GetOrCreateAnnotation(builder.Resource); + var idx = annotation.TenantResolutionCount++; + var prefix = $"{ConfigPrefix}__TenantResolutions__{idx}"; + + builder.WithEnvironment($"{prefix}__Strategy", "Selection"); + if (!string.IsNullOrEmpty(tenantsEndpoint)) + { + builder.WithEnvironment($"{prefix}__Options__TenantsEndpoint", tenantsEndpoint); + } + + return builder; + } + + /// + /// Adds a cookie-selection-based tenant resolution strategy to AuthProxy, deriving the tenants + /// endpoint URL from the specified Aspire service resource. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// The Aspire resource that hosts the selectable-tenants endpoint. + /// + /// The route on the service that returns the selectable tenant list, + /// e.g. "/api/tenants/selectable". + /// + /// The endpoint name to use. Defaults to "http". + /// The same for chaining. + public static IResourceBuilder WithSelectionTenantResolution( + this IResourceBuilder builder, + IResourceBuilder serviceResource, + string route, + string endpointName = "http") + where T : IResourceWithEnvironment + { + var annotation = GetOrCreateAnnotation(builder.Resource); + var idx = annotation.TenantResolutionCount++; + var prefix = $"{ConfigPrefix}__TenantResolutions__{idx}"; + var endpoint = serviceResource.GetEndpoint(endpointName); + + builder.WithEnvironment($"{prefix}__Strategy", "Selection"); + builder.WithEnvironment(context => + context.EnvironmentVariables[$"{prefix}__Options__TenantsEndpoint"] = + ReferenceExpression.Create($"{endpoint}{route}")); + + return builder; + } + + /// + /// Configures AuthProxy to verify that a resolved tenant actually exists by calling an external HTTP endpoint. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// + /// A URL template used to check whether a tenant exists. + /// Use {tenantId} as a placeholder for the resolved tenant identifier, + /// e.g. https://platform.example.com/api/tenants/{tenantId}. + /// An HTTP GET to the resolved URL must return 200 when the tenant exists and 404 when it does not. + /// + /// The same for chaining. + public static IResourceBuilder WithTenantVerification( + this IResourceBuilder builder, + string urlTemplate) + where T : IResourceWithEnvironment => + builder.WithEnvironment($"{ConfigPrefix}__TenantVerification__UrlTemplate", urlTemplate); + + /// + /// Configures AuthProxy to verify that a resolved tenant actually exists by calling an endpoint + /// on the specified Aspire service resource. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// The Aspire resource that hosts the tenant-verification endpoint. + /// + /// The route on the service, including the {tenantId} placeholder, + /// e.g. "/api/tenants/{tenantId}". + /// + /// The endpoint name to use. Defaults to "http". + /// The same for chaining. + public static IResourceBuilder WithTenantVerification( + this IResourceBuilder builder, + IResourceBuilder serviceResource, + string routeTemplate, + string endpointName = "http") + where T : IResourceWithEnvironment + { + var endpoint = serviceResource.GetEndpoint(endpointName); + return builder.WithEnvironment(context => + context.EnvironmentVariables[$"{ConfigPrefix}__TenantVerification__UrlTemplate"] = + ReferenceExpression.Create($"{endpoint}{routeTemplate}")); + } + + /// + /// Configures the AuthProxy invite system with the core invite settings. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// PEM-encoded RSA public key used to verify invite token signatures. + /// + /// Absolute URL of the invite-exchange endpoint called after a successful login with a pending invite token, + /// e.g. https://studio.example.com/internal/invites/exchange. + /// + /// + /// Expected iss claim value. Leave to skip issuer validation. + /// + /// + /// Expected aud claim value. Leave to skip audience validation. + /// + /// + /// Claim in the invite token that carries the tenant ID string (used for tenant-issued invite detection). + /// Leave to use the AuthProxy default. + /// + /// + /// URL to redirect to when the exchange endpoint returns HTTP 409 (subject already registered). + /// Leave to serve the built-in invitation-subject-already-exists.html page. + /// + /// The same for chaining. + public static IResourceBuilder WithInvite( + this IResourceBuilder builder, + string publicKeyPem, + string exchangeUrl, + string? issuer = null, + string? audience = null, + string? tenantClaim = null, + string? subjectAlreadyExistsUrl = null) + where T : IResourceWithEnvironment + { + const string prefix = $"{ConfigPrefix}__Invite"; + + builder + .WithEnvironment($"{prefix}__PublicKeyPem", publicKeyPem) + .WithEnvironment($"{prefix}__ExchangeUrl", exchangeUrl); + + if (!string.IsNullOrEmpty(issuer)) + { + builder.WithEnvironment($"{prefix}__Issuer", issuer); + } + + if (!string.IsNullOrEmpty(audience)) + { + builder.WithEnvironment($"{prefix}__Audience", audience); + } + + if (!string.IsNullOrEmpty(tenantClaim)) + { + builder.WithEnvironment($"{prefix}__TenantClaim", tenantClaim); + } + + if (!string.IsNullOrEmpty(subjectAlreadyExistsUrl)) + { + builder.WithEnvironment($"{prefix}__SubjectAlreadyExistsUrl", subjectAlreadyExistsUrl); + } + + return builder; + } + + /// + /// Configures the AuthProxy invite system, deriving the exchange endpoint URL from the specified Aspire service resource. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// PEM-encoded RSA public key used to verify invite token signatures. + /// The Aspire resource that hosts the invite-exchange endpoint. + /// + /// The route on the exchange service, e.g. "/internal/invites/exchange". + /// + /// The endpoint name to use for the exchange service. Defaults to "http". + /// + /// Expected iss claim value. Leave to skip issuer validation. + /// + /// + /// Expected aud claim value. Leave to skip audience validation. + /// + /// + /// Claim in the invite token that carries the tenant ID string (used for tenant-issued invite detection). + /// Leave to use the AuthProxy default. + /// + /// + /// URL to redirect to when the exchange endpoint returns HTTP 409 (subject already registered). + /// Leave to serve the built-in invitation-subject-already-exists.html page. + /// + /// The same for chaining. + public static IResourceBuilder WithInvite( + this IResourceBuilder builder, + string publicKeyPem, + IResourceBuilder exchangeServiceResource, + string exchangeRoute, + string exchangeEndpointName = "http", + string? issuer = null, + string? audience = null, + string? tenantClaim = null, + string? subjectAlreadyExistsUrl = null) + where T : IResourceWithEnvironment + { + const string prefix = $"{ConfigPrefix}__Invite"; + + var endpoint = exchangeServiceResource.GetEndpoint(exchangeEndpointName); + builder + .WithEnvironment($"{prefix}__PublicKeyPem", publicKeyPem) + .WithEnvironment(context => + context.EnvironmentVariables[$"{prefix}__ExchangeUrl"] = + ReferenceExpression.Create($"{endpoint}{exchangeRoute}")); + + if (!string.IsNullOrEmpty(issuer)) + { + builder.WithEnvironment($"{prefix}__Issuer", issuer); + } + + if (!string.IsNullOrEmpty(audience)) + { + builder.WithEnvironment($"{prefix}__Audience", audience); + } + + if (!string.IsNullOrEmpty(tenantClaim)) + { + builder.WithEnvironment($"{prefix}__TenantClaim", tenantClaim); + } + + if (!string.IsNullOrEmpty(subjectAlreadyExistsUrl)) + { + builder.WithEnvironment($"{prefix}__SubjectAlreadyExistsUrl", subjectAlreadyExistsUrl); + } + + return builder; + } + + /// + /// Adds a claim-forwarding entry to the AuthProxy invite system. + /// When a pending invite cookie exists, AuthProxy reads the specified claim from the invite token + /// and forwards it as part of the principal sent to each /.cratis/me identity details endpoint. + /// Call this method once per claim to forward; multiple calls accumulate entries. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// Claim type to read from the invite token payload. + /// + /// Claim type to emit in the forwarded principal. + /// When the original is used. + /// + /// The same for chaining. + public static IResourceBuilder WithInviteClaimForwarding( + this IResourceBuilder builder, + string fromClaimType, + string? toClaimType = null) + where T : IResourceWithEnvironment + { + var annotation = GetOrCreateAnnotation(builder.Resource); + var idx = annotation.InviteClaimForwardingCount++; + var prefix = $"{ConfigPrefix}__Invite__ClaimsToForward__{idx}"; + + builder.WithEnvironment($"{prefix}__FromClaimType", fromClaimType); + if (!string.IsNullOrEmpty(toClaimType)) + { + builder.WithEnvironment($"{prefix}__ToClaimType", toClaimType); + } + + return builder; + } + + /// + /// Configures the AuthProxy lobby frontend endpoint. + /// The lobby is the service users without a resolved tenant are redirected to + /// while they complete the onboarding / invite-exchange process. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// The Aspire resource that exposes the lobby frontend. + /// The endpoint name to use. Defaults to "http". + /// The same for chaining. + public static IResourceBuilder WithLobbyFrontend( + this IResourceBuilder builder, + IResourceBuilder serviceResource, + string endpointName = "http") + where T : IResourceWithEnvironment + { + var endpoint = serviceResource.GetEndpoint(endpointName); + return builder.WithEnvironment(context => + context.EnvironmentVariables[$"{ConfigPrefix}__Invite__Lobby__Frontend__BaseUrl"] = + ReferenceExpression.Create($"{endpoint}/")); + } + + /// + /// Configures the AuthProxy lobby backend (API) endpoint. + /// The backend is optional — add it only when the lobby service exposes an API that + /// AuthProxy should be able to call or proxy. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// The Aspire resource that exposes the lobby backend. + /// The endpoint name to use. Defaults to "http". + /// The same for chaining. + public static IResourceBuilder WithLobbyBackend( + this IResourceBuilder builder, + IResourceBuilder serviceResource, + string endpointName = "http") + where T : IResourceWithEnvironment + { + var endpoint = serviceResource.GetEndpoint(endpointName); + return builder.WithEnvironment(context => + context.EnvironmentVariables[$"{ConfigPrefix}__Invite__Lobby__Backend__BaseUrl"] = + ReferenceExpression.Create($"{endpoint}/")); + } + + static IResourceBuilder AddTenantResolution(IResourceBuilder builder, string strategy) + where T : IResourceWithEnvironment + { + var annotation = GetOrCreateAnnotation(builder.Resource); + var idx = annotation.TenantResolutionCount++; + return builder.WithEnvironment($"{ConfigPrefix}__TenantResolutions__{idx}__Strategy", strategy); + } + + static AuthProxyConfigAnnotation GetOrCreateAnnotation(IResource resource) + { + if (resource.TryGetLastAnnotation(out var annotation)) + { + return annotation; + } + + var newAnnotation = new AuthProxyConfigAnnotation(); + resource.Annotations.Add(newAnnotation); + return newAnnotation; + } +} diff --git a/Source/Aspire/AuthProxyResource.cs b/Source/Aspire/AuthProxyResource.cs new file mode 100644 index 0000000..5d9d26b --- /dev/null +++ b/Source/Aspire/AuthProxyResource.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Aspire; + +/// +/// Represents the AuthProxy container resource for use in Aspire application models. +/// +/// +/// Initializes a new instance of the class. +/// +/// The resource name. +public class AuthProxyResource(string name) : ContainerResource(name) +{ + /// The Docker Hub image name for AuthProxy. + public const string ContainerImageName = "cratis/authproxy"; + + /// The default Docker image tag (always resolves to the latest stable release). + public const string ContainerImageTag = "latest"; +} diff --git a/Source/Aspire/GlobalUsings.cs b/Source/Aspire/GlobalUsings.cs new file mode 100644 index 0000000..cc96117 --- /dev/null +++ b/Source/Aspire/GlobalUsings.cs @@ -0,0 +1,5 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +global using Aspire.Hosting; +global using Aspire.Hosting.ApplicationModel; diff --git a/Source/Aspire/OidcProviderType.cs b/Source/Aspire/OidcProviderType.cs new file mode 100644 index 0000000..c700f71 --- /dev/null +++ b/Source/Aspire/OidcProviderType.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Aspire; + +/// +/// Represents the type / brand of an OIDC provider, used by the AuthProxy login page to pick the correct logo. +/// +public enum OidcProviderType +{ + /// A generic / unknown provider. + Custom = 0, + + /// Microsoft identity platform (Azure AD / Entra ID). + Microsoft = 1, + + /// Google identity. + Google = 2, + + /// GitHub OAuth / OIDC. + GitHub = 3, + + /// Apple Sign-In. + Apple = 4 +} diff --git a/Source/Composition/AppHost.cs b/Source/Composition/AppHost.cs index d6b2eab..974a5bf 100644 --- a/Source/Composition/AppHost.cs +++ b/Source/Composition/AppHost.cs @@ -1,17 +1,20 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Cratis.AuthProxy.Aspire; + var builder = DistributedApplication.CreateBuilder(args); var testApp = builder.AddProject("testapp") .WithHttpEndpoint(port: 5001); -builder.AddNpmApp("web", "../Web") +var web = builder.AddNpmApp("web", "../Web") .WithHttpEndpoint(port: 9100, env: "PORT"); builder.AddProject("authproxy") .WithHttpEndpoint(port: 8080) - .WithReference(testApp) + .WithBackend("main", testApp) + .WithFrontend("main", web) .WaitFor(testApp); await builder.Build().RunAsync(); diff --git a/Source/Composition/Composition.csproj b/Source/Composition/Composition.csproj index 53f4fb5..d0b960b 100644 --- a/Source/Composition/Composition.csproj +++ b/Source/Composition/Composition.csproj @@ -13,6 +13,7 @@ +