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
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
<!-- Identity's PasswordHasher only (PBKDF2, versioned, rehash-on-verify) — not the user/role
stack. Delegated deliberately rather than hand-rolling password hashing. -->
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.10" />
<!-- OIDC for the human-facing UI (#48). The *arr surface stays key-based by protocol. -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.10" />
</ItemGroup>

<ItemGroup Label="Validation">
Expand Down
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ Built on .NET 10, Postgres, Wolverine and .NET Aspire.
> **The full round trip is proven** — grab → download → Sonarr import → file in the library, against a
> real Sonarr 4.0.19, for both dated and numbered shows.
>
> **What is *not* there yet:** **OIDC**
> ([#48](../../issues/48) — local login works, `oidc` is a stub); and **`*arr` reach-back** to pre-warm
> the crawl list ([#6](../../issues/6) — optional by design, see DR-011).
> Identity is either the built-in local login or **your own OIDC provider** — see
> [Authentication](#authentication).
>
> Credentials you enter can be kept out of the database entirely as
> [secret references](#keeping-secrets-out-of-the-database); stored literally, they are plain text.
Expand Down Expand Up @@ -265,10 +264,25 @@ The UI requires a sign-in. `Auth:Provider` selects how:
| Value | Behaviour |
|---|---|
| `local` (default) | Built-in single administrator, created on first run |
| `oidc` | Delegate to your own identity provider — Authentik, Keycloak, Authelia, Entra *(not yet implemented)* |
| `oidc` | Delegate to your own identity provider — Authentik, Keycloak, Authelia, Entra |
| `none` | No authentication — only for deployments already behind reverse-proxy forward-auth |

**First run:** there is no administrator yet, so the `web` host logs a one-time setup link. Fetch it from
**With an identity provider:**

```dotenv
AUTH_PROVIDER=oidc
OIDC_AUTHORITY=https://identity.example.com/application/o/krautwatch/
OIDC_CLIENT_ID=…
OIDC_CLIENT_SECRET=… # may be env:/file:
OIDC_REQUIRED_GROUPS=Homelab Admins,Media Admins # optional second check; empty admits anyone
```

Register `https://<host>/signin-oidc` as the redirect URI and request `openid profile email`. Setting
`OIDC_REQUIRED_GROUPS` even though your provider already gates the application is deliberate: two
independent checks mean neither has to be the only thing that is right. Sign-out is federated, and
`/setup` stops asking for an administrator — your provider owns identity.

**First run (local provider):** there is no administrator yet, so the `web` host logs a one-time setup link. Fetch it from
the logs and open it:

```bash
Expand Down
100 changes: 100 additions & 0 deletions docs/plans/2026-08-23 - oidc authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# OIDC authentication (#48)

**Date:** 2026-08-23
**Status:** agreed, being implemented

## What is already true

Most of #48 shipped with local auth. `Auth:Provider` already selects the scheme in the Web host's
composition root, `local` and `none` work, every routable page carries an authorization decision
(asserted by `PageAuthorizationSpecs`), and both paths land on the same cookie and `ClaimsPrincipal`.
`oidc` is the one value that does nothing.

The machine surface is out of scope by protocol: Sonarr can only send an `apikey` query parameter, so
`Api/NewznabIndexerApi` stays keyed however the human surface is configured.

## The shape, and what it is not

**No `IAuthenticationProvider` port.** The issue proposed one; the shipped design deliberately did not
build it, and that reasoning holds. Local credentials are a verification concern that fits a port
(`ILocalCredentialStore` + `IPasswordHasher`). OIDC is a redirect/token protocol owned end to end by
framework middleware — there is no behaviour left for a Domain interface to abstract, and inventing
one would mean wrapping `AddOpenIdConnect` in a port that only ever has one implementation.

So: `Auth:Provider = oidc` adds the OpenID Connect handler beside the existing cookie handler, and
everything downstream — pages, `[Authorize]`, the cascading auth state — is untouched.

```
Auth:Provider = local → cookie + local credential store (unchanged)
Auth:Provider = oidc → cookie + OpenIdConnect challenge (this plan)
Auth:Provider = none → AnonymousAccess middleware (unchanged)
```

## Configuration

```jsonc
{
"Auth": {
"Provider": "oidc",
"Oidc": {
"Authority": "https://identity.chrison.dev/application/o/krautwatch/",
"ClientId": "…",
"ClientSecret": "…", // may be a secret reference (env:/file:)
"Scopes": ["openid", "profile", "email"],
"RequiredGroups": ["Homelab Admins", "Media Admins"],
"GroupsClaim": "groups"
}
}
}
```

`RequiredGroups` empty (the default) means anyone the IdP authenticates gets in — other people's
identity providers must not be forced into this homelab's group model.

## Two layers of authorisation, deliberately

The homelab's identity blueprint states the principle already: *authenticated must never imply
authorised*, because the Plex source auto-enrols friends. This adopts it rather than restating it:

1. **Authentik** decides who may obtain a token for Krautwatch at all — application policy bindings
for `Homelab Admins` and `Media Admins`.
2. **Krautwatch** re-checks the `groups` claim against `RequiredGroups` on sign-in, and refuses
otherwise.

Either layer alone would be enough on a good day. The point is that neither depends on the other
being right — a binding deleted by a careless converge should not silently open the UI.

## Work

1. **`OidcOptions`** bound from `Auth:Oidc`, with the client secret resolved through `ISecretResolver`
like every other stored credential.
2. **Wire `AddOpenIdConnect`** in the Web host when the provider is `oidc`: PKCE on, tokens not
persisted (nothing here calls the IdP's APIs), `SaveTokens = false`, scopes from config, and the
`groups` claim mapped so `RequiredGroups` and the UI can read it.
3. **Group enforcement** in `OnTokenValidated` — reject with a clear failure rather than signing in a
principal that later fails every page.
4. **`/login` under OIDC challenges instead of showing a form.** The password form is meaningless
there, and a page that looks like it accepts credentials but cannot is worse than a redirect.
5. **`/logout` signs out federated.** Cookie-only sign-out leaves the IdP session intact, so the next
visit silently signs straight back in — which reads as "logout is broken".
6. **First-run setup skips the administrator step** when the provider is `oidc`: there is no local
admin to create, and `SetupStateHandler` currently answers "setup required" forever without one.
The wizard's remaining steps (downloads, egress, `*arr`) still apply.
7. **Tests**: options binding and secret resolution, the group check (member, non-member, claim
absent, no requirement configured), the setup-state change, and `PageAuthorizationSpecs` staying
green.
8. **Docs**: README + self-hosting, with the Authentik registration spelled out since that is the
provider this was built against.

## Authentik registration (separate PR, Homelab repo)

Following the Pangolin entry in `stacks/Core/authentik/assets/blueprints/00-homelab-identity.yaml`:
an `oauth2provider` (confidential, `client_id`/`client_secret` as `!Env` from Bitwarden Secrets
Manager, `include_claims_in_id_token: true`, explicit `grant_types`), an `application`, and policy
bindings for the two admin groups. Opened as a PR for converge rather than applied from here.

## Not in scope

- Changing the `*arr` API key model (#48's last checkbox) — a separate concern with its own trade-offs.
- Local and OIDC side by side. One provider at a time keeps "who can sign in" answerable from one
setting; a deployment that wants both has a reverse proxy for it.
27 changes: 26 additions & 1 deletion docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,32 @@ Once an administrator exists, `/setup` never reopens.
|---|---|
| `local` *(default)* | built-in single administrator, as above |
| `none` | no authentication — **only** behind reverse-proxy forward-auth |
| `oidc` | **not implemented yet** ([#48](https://github.com/Chrison-dev/Krautwatch/issues/48)) |
| `oidc` | hand identity to your own provider — see below |

### Signing in with your own identity provider

```dotenv
AUTH_PROVIDER=oidc
OIDC_AUTHORITY=https://identity.example.com/application/o/krautwatch/
OIDC_CLIENT_ID=<from your provider>
OIDC_CLIENT_SECRET=<from your provider> # may be env:/file: like any stored credential
OIDC_REQUIRED_GROUPS=Homelab Admins,Media Admins # optional; empty admits anyone it authenticates
```

The redirect URI to register is **`https://<your-krautwatch-host>/signin-oidc`**, and the sign-out
URI is `https://<your-krautwatch-host>/signout-callback-oidc`. Request the `openid profile email`
scopes; `profile` is what carries group membership on Authentik.

**`OIDC_REQUIRED_GROUPS` is worth setting even though your provider already decides who gets a
token.** Two independent checks means a policy deleted by a careless change on either side does not
silently open the UI. Leave it empty if your provider has no group model — Krautwatch then admits
whoever completes the flow.

`/setup` stops asking for an administrator under `oidc`: your provider owns identity and no local
account is ever created. The rest of the wizard — downloads, egress, `*arr` — still applies.

Signing out is federated: it clears the Krautwatch cookie *and* ends the provider session, because
dropping only ours means the next visit signs straight back in and looks broken.

---

Expand Down
21 changes: 19 additions & 2 deletions src/Application/Auth/SignIn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,25 @@ await store.CreateAsync(new AdminAccount
}

/// <summary>Whether first-run setup is still pending — drives both the /setup gate and the startup log.</summary>
public class SetupStateHandler(ILocalCredentialStore store)
/// <remarks>
/// "Setup" here means <b>creating the local administrator</b>, so it only ever applies to the local
/// provider. Under <c>oidc</c> the IdP owns identity and no local account will ever exist; without this
/// the wizard would demand one forever, the sign-in page would claim the instance is unconfigured, and
/// the startup log would print a setup link that leads to a step nobody can complete (#48).
/// </remarks>
public class SetupStateHandler(ILocalCredentialStore store, LocalAdminRequirement? requirement = null)
{
public async Task<bool> IsSetupRequiredAsync(CancellationToken ct = default) =>
!await store.ExistsAsync(ct);
(requirement?.Required ?? true) && !await store.ExistsAsync(ct);
}

/// <summary>
/// Whether this deployment needs a local administrator at all — registered by the host that knows
/// which authentication scheme is configured.
/// </summary>
/// <remarks>
/// A one-field type rather than reading configuration in the Application layer: the slice stays
/// testable without an <c>IConfiguration</c>, and the composition root keeps its job of deciding what
/// the scheme is. Absent means required, so hosts that never wired it behave exactly as before.
/// </remarks>
public sealed record LocalAdminRequirement(bool Required);
67 changes: 67 additions & 0 deletions src/Domain/Options/OidcOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
namespace Krautwatch.Domain.Options;

/// <summary>
/// OpenID Connect settings for the human-facing UI, bound from <c>Auth:Oidc</c> (#48).
/// </summary>
/// <remarks>
/// Domain-side because the group check is a rule about who may use Krautwatch, not a detail of the
/// protocol handler — the wiring lives in the Web host, the policy lives here and is testable without
/// an identity provider.
/// </remarks>
public sealed class OidcOptions
{
public const string SectionName = "Auth:Oidc";

/// <summary>The IdP's issuer, e.g. <c>https://identity.example.com/application/o/krautwatch/</c>.</summary>
public string Authority { get; set; } = "";

public string ClientId { get; set; } = "";

/// <summary>May be a secret reference (<c>env:</c> / <c>file:</c>) like any other stored credential.</summary>
public string ClientSecret { get; set; } = "";

/// <summary>Requested scopes. <c>profile</c> is what carries the groups claim on Authentik.</summary>
public List<string> Scopes { get; set; } = ["openid", "profile", "email"];

/// <summary>
/// Groups a user must be in — <b>any one of them</b> — to be admitted.
/// </summary>
/// <remarks>
/// Empty by default, meaning anyone the IdP authenticated gets in. That default matters: this has
/// to work against identity providers that carry no group model at all, so a homelab's own access
/// tiers cannot be a precondition for the feature.
/// <para>
/// Where it is set, it is a <i>second</i> layer rather than the only one — the IdP should also be
/// refusing to issue a token for this application. Neither layer relies on the other being right,
/// which is the point: a policy binding deleted by a careless deploy must not silently open the UI.
/// </para>
/// </remarks>
public List<string> RequiredGroups { get; set; } = [];

/// <summary>The claim carrying group membership. Authentik emits <c>groups</c> in <c>profile</c>.</summary>
public string GroupsClaim { get; set; } = "groups";

/// <summary>
/// Splits a comma-separated group list, for deployments configured through environment variables.
/// </summary>
/// <remarks>
/// Configuration binds a list from indexed keys (<c>…RequiredGroups__0</c>), which a compose
/// <c>.env</c> has no comfortable way to express. Accepting <c>"Homelab Admins,Media Admins"</c>
/// as well means the setting can be written the way every other one in that file is.
/// </remarks>
public static List<string> ParseGroups(string? commaSeparated) =>
string.IsNullOrWhiteSpace(commaSeparated)
? []
: commaSeparated
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();

/// <summary>Whether a principal is allowed in, given the groups their token carries.</summary>
/// <remarks>
/// Case-insensitive: group names are human-typed in two places (an IdP's UI and our config), and a
/// casing mismatch failing closed would look exactly like a permissions problem.
/// </remarks>
public bool Admits(IEnumerable<string> groups) =>
RequiredGroups.Count == 0
|| groups.Any(g => RequiredGroups.Contains(g, StringComparer.OrdinalIgnoreCase));
}
16 changes: 16 additions & 0 deletions src/Presentation/AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@
// Optional: TheTVDB matching. Absent is fine — matching degrades to titles rather than failing.
var tvdbApiKey = builder.AddParameter("tvdb-apikey", secret: true);

// Optional: hand identity to an OIDC provider instead of the built-in local login (#48). Blank means
// local, which is what a fresh install should be — an instance that comes up asking for an IdP nobody
// configured is an instance nobody can sign in to.
var authProvider = builder.AddParameter("auth-provider", secret: false);
var oidcAuthority = builder.AddParameter("oidc-authority", secret: false);
var oidcClientId = builder.AddParameter("oidc-client-id", secret: false);
var oidcClientSecret = builder.AddParameter("oidc-client-secret", secret: true);
var oidcRequiredGroups = builder.AddParameter("oidc-required-groups", secret: false);

// Optional: a first Sonarr/Radarr instance, so a compose deployment can arrive already wired up
// instead of requiring a trip through Settings (#5). Applied by the Web host only while no instance
// exists — after that the UI owns them, and these are ignored.
Expand Down Expand Up @@ -102,6 +111,13 @@
.WithEnvironment("SONARR_API_KEY", sonarrApiKey)
.WithEnvironment("RADARR_URL", radarrUrl)
.WithEnvironment("RADARR_API_KEY", radarrApiKey)
.WithEnvironment("Auth__Provider", authProvider)
.WithEnvironment("Auth__Oidc__Authority", oidcAuthority)
.WithEnvironment("Auth__Oidc__ClientId", oidcClientId)
.WithEnvironment("Auth__Oidc__ClientSecret", oidcClientSecret)
// Comma-separated, parsed by the Web host: a .env file has nowhere to put an array, and
// configuration alone only binds indexed keys.
.WithEnvironment("Auth__Oidc__RequiredGroups", oidcRequiredGroups)
.WithExternalHttpEndpoints();

// ──────────────────────────────────────────────────────────────
Expand Down
33 changes: 32 additions & 1 deletion src/Presentation/Web/Components/Pages/Login.razor
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
@using Krautwatch.Application.Auth
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Authentication.Cookies
@using Microsoft.AspNetCore.Authentication.OpenIdConnect
@inject SignInHandler SignIn
@inject SetupStateHandler SetupState
@inject NavigationManager Nav
@inject IHttpContextAccessor HttpContextAccessor
@inject IConfiguration Config

@* Static SSR deliberately (no @rendermode): writing the auth cookie needs HttpContext before the
response has started, which an interactive circuit cannot do. *@
Expand All @@ -17,7 +19,14 @@
<div class="auth-card">
<h1>Sign in</h1>

@if (_setupRequired)
@if (_oidc)
{
@* No form here on purpose: under OIDC there are no credentials for this page to take, and a
password box that cannot work is worse than a redirect. OnInitialized challenges before
this ever renders; this is what a visitor sees if the redirect is slow. *@
<p class="muted">Redirecting to your identity provider…</p>
}
else if (_setupRequired)
{
<p class="muted">
This instance has not been set up yet. Check the host log for the setup link —
Expand Down Expand Up @@ -56,8 +65,30 @@
private string? _error;
private bool _setupRequired;

bool _oidc;

protected override async Task OnInitializedAsync()
{
_oidc = string.Equals(Config["Auth:Provider"], "oidc", StringComparison.OrdinalIgnoreCase);

if (_oidc)
{
var http = HttpContextAccessor.HttpContext;

// Challenge rather than render: the IdP owns sign-in entirely, so the only useful thing
// this route can do is hand the visitor over — carrying the return URL, so they land where
// they were going rather than on the home page.
if (http is not null && http.User.Identity?.IsAuthenticated != true)
{
await http.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme,
new AuthenticationProperties { RedirectUri = ReturnUrl ?? "/" });
return;
}

Nav.NavigateTo(ReturnUrl ?? "/", forceLoad: true);
return;
}

Input ??= new();
_setupRequired = await SetupState.IsSetupRequiredAsync();
}
Expand Down
Loading
Loading