Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions Documentation/configuration/link.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Credential Linking

AuthProxy can let an **already signed-in** user prove control of an additional identity-provider login and
associate it with their existing account — without ever replacing their current session. This is the
proof-of-control building block behind an application's "add a credential" feature.

```
GET /.cratis/link/{scheme}?returnUrl=<relative-url>&token=<one-time-link-token>
```

It is deliberately **not** the same as `/.cratis/login/{scheme}`: login signs the authenticated identity into
the primary session cookie (which, for a second identity, would swap who the user is — effectively logging
them out of the original account). The link flow authenticates the second provider, captures its subject,
and hands that subject to the application, all while leaving the primary session untouched.

---

## Why a popup, not an iframe

The application opens `/.cratis/link/{scheme}` in a **popup** (or a top-level redirect), never a nested
iframe. Provider consent pages send `X-Frame-Options: DENY`, and AuthProxy's authentication and OAuth
correlation/nonce cookies are `SameSite=Lax`, so a cross-site iframe cannot complete the flow. A same-origin
popup avoids both problems.

---

## How it works

1. **The application mints a one-time link token.** When the user starts "add a credential", the application
issues a short-lived, single-use token bound to that signed-in user, and opens the popup at
`/.cratis/link/{scheme}?returnUrl=…&token=…`. The `scheme` is a configured provider scheme (the same value
used by `/.cratis/login/{scheme}`, e.g. `github`).
2. **AuthProxy challenges the provider.** The request must come from an authenticated session (an anonymous
request is rejected with `401`; an unknown scheme with `404`; a missing token with `400`). AuthProxy starts
an OAuth/OIDC challenge for the requested scheme, carrying a link-mode marker and the link token through the
authentication properties.
3. **On the provider callback the identity is captured, not signed in.** Instead of writing the primary
authentication cookie, AuthProxy reads the freshly authenticated `subject` (and identity provider) and
`POST`s them to the configured [`ExchangeUrl`](#configuration), authenticated with the link token as the
bearer credential — exactly mirroring the [invite exchange](./lobby/invitation-to-organization.md). The
user's original session is preserved.
4. **AuthProxy returns to the application.** The browser is redirected to the supplied `returnUrl`, where the
application correlates the token back to the signed-in user, records the association, and closes the popup.

The request body posted to `ExchangeUrl` matches the invite exchange shape:

```json
{ "subject": "<provider subject>", "identityProvider": "<issuer / provider>" }
```

with `Authorization: Bearer <one-time-link-token>`.

---

## The `returnUrl` parameter

`returnUrl` is echoed back to the browser after the link completes, so it is constrained to a **same-site
relative path** (a single leading `/`, but not `//`). Anything else — including an absolute URL to another
origin — falls back to the application root (`/`), so the endpoint can never be turned into an open redirect.

---

## Configuration

Set the application endpoint that records the freshly authenticated subject under
`Cratis:AuthProxy:Link:ExchangeUrl`. It is the link counterpart of `Cratis:AuthProxy:Invite:ExchangeUrl`.

```json
{
"Cratis": {
"AuthProxy": {
"Link": {
"ExchangeUrl": "https://studio.example.com/api/internal/identity-providers/link"
}
}
}
}
```

Equivalent environment variable:

```
Cratis__AuthProxy__Link__ExchangeUrl=https://studio.example.com/api/internal/identity-providers/link
```

When `ExchangeUrl` is empty or the `Link` section is absent, the link callback is skipped (the subject is not
posted anywhere) and the flow is effectively disabled.

> **Security.** The exchange endpoint is a service-to-service back-channel: it is reachable by AuthProxy, not by
> browsers, and it trusts the subject AuthProxy delivers from a real provider authentication together with the
> one-time link token — never a client-supplied subject. Point `ExchangeUrl` at an internal application address
> and keep the token short-lived and single-use, exactly as for the invite exchange.
98 changes: 98 additions & 0 deletions Documentation/configuration/logout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Logout

AuthProxy exposes a well-known logout endpoint that ends the current session and returns the user to a
validated destination:

```
GET /.cratis/logout?redirect=<absolute-url>
```

The endpoint is anonymous — it works even when the session is already invalid — and is handled before
the authentication challenge stages, so it never bounces the user to a login provider.

---

## What it clears

A logout request:

1. Signs the user out of the authentication cookie (`.Cratis.AuthProxy.Auth.v2`), including any chunked variants.
2. Deletes every AuthProxy session cookie:
- `.cratis-identity`
- `.cratis-tenant`
- `.cratis-tenants`
- `.cratis-invite`
- `.cratis-registration`
- `.cratis-providers`
3. Redirects (`302 Found`) to the validated `redirect` target.

---

## The `redirect` parameter

The post-logout destination is supplied as an **absolute URL** in the `redirect` query-string parameter,
for example:

```
/.cratis/logout?redirect=https://cratis.studio
```

Because the target is absolute, it cannot be validated with the relative-URL check used elsewhere.
Instead it is matched against an **allow-list of origins** so the endpoint can never be turned into an
open redirect. A target is allowed when its origin (scheme + host + port) matches any of:

- The proxy's **own public origin** as seen by the browser (derived from the request, honoring
`X-Forwarded-Proto`). This covers redirecting back to the site the user is already on.
- Any configured **service frontend** (`Cratis:AuthProxy:Services:<name>:Frontend:BaseUrl`).
- The configured **lobby frontend** (`Cratis:AuthProxy:Invite:Lobby:Frontend:BaseUrl`).
- Any origin listed in **`Cratis:AuthProxy:Logout:AllowedRedirectOrigins`** (see below).

Same-site **relative** URLs (a single leading `/`, but not `//`) are always allowed.

If `redirect` is missing, empty, or fails validation, AuthProxy falls back to the application root (`/`).

---

## Allowing additional post-logout origins

The implicit origins above cover redirecting back to the app itself, but a deployment often wants to send
the user somewhere else after logout — for example a separate marketing or landing site that is neither
the proxy's own origin nor a configured frontend. List those origins under
`Cratis:AuthProxy:Logout:AllowedRedirectOrigins`. Each entry is an absolute origin (scheme + host,
optionally a port) with no path; malformed or non-HTTP(S) entries are ignored.

```json
{
"Cratis": {
"AuthProxy": {
"Logout": {
"AllowedRedirectOrigins": [
"https://cratis.studio"
]
}
}
}
}
```

Equivalent environment variables (one indexed key per entry):

```
Cratis__AuthProxy__Logout__AllowedRedirectOrigins__0=https://cratis.studio
```

With the example above, `GET /.cratis/logout?redirect=https://cratis.studio` is permitted even when the
app itself is served from a different host such as `https://app.cratis.studio`.

---

## Example

A "Log out" control in the application simply navigates the browser to the endpoint:

```html
<a href="/.cratis/logout?redirect=https://cratis.studio">Log out</a>
```

After the redirect the user lands on the target unauthenticated; requesting a protected resource then
triggers the normal login flow.
28 changes: 25 additions & 3 deletions Documentation/configuration/tenant-selection.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,34 @@ The page receives tenant data through a cookie, not by calling your tenant endpo

1. Authenticated request arrives without a `.cratis-tenant` cookie.
2. AuthProxy calls the configured `Selection.Options.TenantsEndpoint`.
3. If exactly one tenant is returned, AuthProxy sets `.cratis-tenant` immediately and redirects to the original URL (no selection page shown).
4. If more than one tenant is returned, AuthProxy writes `.cratis-tenants` (URL-encoded JSON array) and serves `select-tenant.html`.
3. If exactly one tenant is returned, AuthProxy sets `.cratis-tenant` immediately, removes any `.cratis-tenants` cookie, and redirects to the original URL (no selection page shown).
4. If more than one tenant is returned, AuthProxy writes `.cratis-tenants` (URL-encoded JSON array) as a session cookie and serves `select-tenant.html`.
5. User clicks a tenant option.
6. Browser navigates to `/.cratis/select-tenant?tenantId=<id>&returnUrl=<path>`.
7. AuthProxy validates the selected `tenantId` against `TenantsEndpoint` and sets `.cratis-tenant`.
8. AuthProxy redirects back to `returnUrl`.
8. AuthProxy redirects back to `returnUrl`. The `.cratis-tenants` cookie is **retained** so the application can offer an in-app tenant switcher.

---

## Switching tenants after selection

For a user with **more than one** tenant, `.cratis-tenants` is written as a **session cookie** and is
**not** deleted when a tenant is selected. It therefore remains available for the rest of the browser
session, which lets the application's toolbar decide whether to show a "switch tenant" control (show it
only when the cookie lists more than one tenant).

To switch, the toolbar navigates to the same selection endpoint used by the selection page:

```
/.cratis/select-tenant?tenantId=<id>&returnUrl=<current path>
```

Every switch re-validates the requested `tenantId` against `TenantsEndpoint`, so a stale cookie can
never grant access to a tenant the user is no longer a member of — an unknown `tenantId` is rejected
with `400 Bad Request`.

A user with exactly **one** tenant never receives `.cratis-tenants` (it is removed when the single
tenant is auto-selected), so no switcher is shown for single-tenant users.

---

Expand Down
4 changes: 4 additions & 0 deletions Documentation/configuration/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
href: tenancy.md
- name: Tenant Selection Page
href: tenant-selection.md
- name: Logout
href: logout.md
- name: Credential Linking
href: link.md
- name: Services
href: services.md
- name: Lobby
Expand Down
20 changes: 20 additions & 0 deletions Source/AuthProxy.Specs/Links/RecordingHttpMessageHandler.cs
Original file line number Diff line number Diff line change
@@ -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.

using System.Net;

namespace Cratis.AuthProxy.Links;

public class RecordingHttpMessageHandler(HttpStatusCode statusCode = HttpStatusCode.OK) : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }

public string? LastRequestBody { get; private set; }

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
LastRequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(statusCode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Net;
using Microsoft.AspNetCore.Authentication;

namespace Cratis.AuthProxy.Links.for_LinkSubjectExchanger.given;

public class a_link_subject_exchanger : Specification
{
protected const string ExchangeUrl = "https://studio.example.com/api/internal/identity-providers/link";
protected const string LinkToken = "the-one-time-link-token";

protected LinkSubjectExchanger _exchanger;
protected RecordingHttpMessageHandler _handler;
protected IOptionsMonitor<C.AuthProxy> _config;
protected ClaimsPrincipal _principal;
protected AuthenticationProperties _properties;

protected virtual C.AuthProxy CreateConfig() => new() { Link = new C.Link { ExchangeUrl = ExchangeUrl } };

protected virtual HttpStatusCode ExchangeStatusCode => HttpStatusCode.OK;

protected virtual AuthenticationProperties CreateProperties()
{
var properties = new AuthenticationProperties();
properties.Items[LinkMiddleware.LinkTokenPropertyKey] = LinkToken;
return properties;
}

void Establish()
{
_config = Substitute.For<IOptionsMonitor<C.AuthProxy>>();
_config.CurrentValue.Returns(CreateConfig());

_handler = new RecordingHttpMessageHandler(ExchangeStatusCode);
var httpClientFactory = Substitute.For<IHttpClientFactory>();
httpClientFactory.CreateClient(Arg.Any<string>()).Returns(_ => new HttpClient(_handler));

_principal = new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim("sub", "linked-subject-123"),
new Claim("iss", "https://github.com"),
],
"github"));

_properties = CreateProperties();

_exchanger = new LinkSubjectExchanger(_config, httpClientFactory, Substitute.For<ILogger<LinkSubjectExchanger>>());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// 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.Links.for_LinkSubjectExchanger.given;

namespace Cratis.AuthProxy.Links.for_LinkSubjectExchanger;

public class when_exchanging_a_subject : a_link_subject_exchanger
{
LinkExchangeResult _result;

async Task Because() => _result = await _exchanger.Exchange(_principal, _properties);

[Fact] void should_succeed() => _result.ShouldEqual(LinkExchangeResult.Success);
[Fact] void should_post_to_the_configured_exchange_url() => _handler.LastRequest!.RequestUri!.ToString().ShouldEqual(ExchangeUrl);
[Fact] void should_post() => _handler.LastRequest!.Method.ShouldEqual(HttpMethod.Post);
[Fact] void should_authenticate_with_the_link_token() => _handler.LastRequest!.Headers.Authorization!.Parameter.ShouldEqual(LinkToken);
[Fact] void should_use_the_bearer_scheme() => _handler.LastRequest!.Headers.Authorization!.Scheme.ShouldEqual("Bearer");
[Fact] void should_send_the_linked_subject() => _handler.LastRequestBody!.ShouldContain("linked-subject-123");
[Fact] void should_send_the_identity_provider() => _handler.LastRequestBody!.ShouldContain("https://github.com");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Net;
using Cratis.AuthProxy.Links.for_LinkSubjectExchanger.given;

namespace Cratis.AuthProxy.Links.for_LinkSubjectExchanger;

public class when_the_endpoint_returns_an_error : a_link_subject_exchanger
{
LinkExchangeResult _result;

protected override HttpStatusCode ExchangeStatusCode => HttpStatusCode.InternalServerError;

async Task Because() => _result = await _exchanger.Exchange(_principal, _properties);

[Fact] void should_fail() => _result.ShouldEqual(LinkExchangeResult.Failed);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// 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.Links.for_LinkSubjectExchanger.given;

namespace Cratis.AuthProxy.Links.for_LinkSubjectExchanger;

public class when_the_exchange_url_is_not_configured : a_link_subject_exchanger
{
LinkExchangeResult _result;

protected override C.AuthProxy CreateConfig() => new() { Link = null };

async Task Because() => _result = await _exchanger.Exchange(_principal, _properties);

[Fact] void should_fail() => _result.ShouldEqual(LinkExchangeResult.Failed);
[Fact] void should_not_post_anything() => _handler.LastRequest.ShouldBeNull();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// 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.Links.for_LinkSubjectExchanger.given;
using Microsoft.AspNetCore.Authentication;

namespace Cratis.AuthProxy.Links.for_LinkSubjectExchanger;

public class when_the_link_token_is_missing : a_link_subject_exchanger
{
LinkExchangeResult _result;

protected override AuthenticationProperties CreateProperties() => new();

async Task Because() => _result = await _exchanger.Exchange(_principal, _properties);

[Fact] void should_fail() => _result.ShouldEqual(LinkExchangeResult.Failed);
[Fact] void should_not_post_anything() => _handler.LastRequest.ShouldBeNull();
}
Loading
Loading