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
56 changes: 56 additions & 0 deletions docs/stack-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ Available template variables:
| `{url}`, `{<id>_url}`, `{home_url}` | Public URLs: `http://<ip>:<port>` in port mode; `https://<id>.<domain>` in domain mode once `[core].dns_provider` is set, `http://` before that |
| `{url_host}` | The host of `{url}`: `<id>.<domain>` in domain mode, `{ip}` in port mode. For a protocol the stacklet serves under the same name besides HTTP, such as Forgejo's SSH |
| `{<id>__<NAME>}` | A secret from `secrets.toml`, e.g. `{docs__API_TOKEN}`, `{infra__DNS_API_TOKEN}` |
| `{oidc_issuer}`, `{oidc_client_id}`, `{oidc_client_secret}` | Single sign-on for a stacklet with an `[oidc]` table, see [Single Sign-On](#single-sign-on-oidc). All three empty until the provider has registered the stacklet |

### Hints

Expand Down Expand Up @@ -472,6 +473,61 @@ A stacklet may declare zero, one, or several entries of each kind. Sources
flow to every configured target whose engine supports the declared
section type.

### Single Sign-On (OIDC)

A stacklet can let people log in through an OpenID Connect provider that
another stacklet runs. The two sides do not name each other: the client
does not know which provider it gets, and the provider does not know the
client's env.

A **client** declares `[oidc]` and reads three template variables:

```toml
[env.defaults]
OIDC_ISSUER = "{oidc_issuer}"
OIDC_CLIENT_ID = "{oidc_client_id}"
OIDC_CLIENT_SECRET = "{oidc_client_secret}"

[oidc]
name = "Documents" # shown on the provider's consent page; default: the stacklet name
callbacks = ["{url}/accounts/oidc/sso/login/callback/"]
```

`callbacks` are rendered against the client's own template variables, so
`{url}` is the client's public URL. The three `{oidc_*}` variables are
set together or not at all: they stay empty while no provider exists, and
until the provider has registered the client. Test one of them in the
compose file to switch the login on, and keep the service's own login as
the fallback.

A **provider** declares `[oidc_provider]` (an empty table). The issuer is
its public URL, `{url}`. Its hooks do the registration through the
`Stack` instance in `ctx.stack`:

| Method | What it does |
|---|---|
| `oidc_clients()` | Every stacklet with `[oidc]`: `stacklet`, `name`, rendered `callbacks`, and the stored `client_id` and `client_secret` (None before the first registration) |
| `store_oidc_client(stacklet_id, client_id, client_secret)` | Stores the credentials the provider issued |

Credentials are kept in the provider's secret namespace
(`<provider>__CLIENT_<ID>_ID` and `_SECRET`). `stack destroy <provider>`
deletes them, and each client falls back to its own login on its next
`stack up`. A client reads new credentials only when its containers are
recreated, so a provider should not rotate a stored secret on every run,
and should tell the admin which client to `stack up` after storing new
credentials. If more than one stacklet declares `[oidc_provider]`, the
first one discovered is used.

`stack doctor` warns about a running client that a running provider has
not registered, and about a second provider being ignored. A provider
that is present but was never brought up raises nothing.

A provider may need domain mode with HTTPS, and should refuse to start
without it in `on_install` and `on_start`. A passkey login is the usual
reason: browsers allow WebAuthn only on an `https://` origin. The
framework does not check this, because the requirement belongs to the
provider, not to OIDC.

---

## Lifecycle
Expand Down
7 changes: 7 additions & 0 deletions lib/stack/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1083,6 +1083,13 @@ def missing_secrets(stacklet_id):
stale=stck.list().get("stale", []),
)

findings += doctor.check_oidc(
[s["id"] for s in discovered if "oidc_provider" in s["manifest"]],
{c["stacklet"]: bool(c["client_id"] and c["client_secret"])
for c in stck.oidc_clients()},
running={sid for sid in stacklets if docker.containers_for(sid)},
)

# What this instance is running, and whether a release has passed it.
# No fetch: doctor is run often, and often when something is
# unreachable, so it answers from the tags this clone already has.
Expand Down
44 changes: 44 additions & 0 deletions lib/stack/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,50 @@ def check_endpoint(name: str, url: str, reachable: bool) -> Finding | None:
)


def check_oidc(providers: list[str], clients: dict[str, bool],
running: set[str]) -> list[Finding]:
"""Single sign-on that is set up on one side only.

A client's `{oidc_*}` variables render empty both when there is no
provider and when the provider has not registered it yet, and either
way the service shows only its own login. The first is a choice, the
second a gap. `providers` are the stacklets declaring
`[oidc_provider]` in discovery order, `clients` maps each stacklet
declaring `[oidc]` to whether its credentials are stored, `running`
names the stacklets with containers.

A provider that is not running raises nothing: an extension in the
directory is not yet a decision to use it.
"""
findings: list[Finding] = []
if len(providers) > 1:
used, ignored = providers[0], ", ".join(providers[1:])
findings.append(Finding(
level=WARN,
title="more than one stacklet provides single sign-on",
detail=f"{used} is used, {ignored} is ignored. Only the first "
"stacklet that declares [oidc_provider] is used.",
fix=f"remove [oidc_provider] from {ignored}",
))
if not providers or providers[0] not in running:
return findings
provider = providers[0]
for stacklet, registered in sorted(clients.items()):
if registered or stacklet not in running:
continue
findings.append(Finding(
level=WARN,
title=f"{stacklet} has no single sign-on yet",
detail=f"It declares [oidc] and {provider} provides OpenID "
f"Connect, but {provider} has not registered it, so "
f"{stacklet} shows only its own login. Registration "
f"runs on `stack up {provider}`; then run "
f"`stack up {stacklet}` to apply the credentials.",
fix=f"stack up {provider}",
))
return findings


def diagnose(stacklets, expected_env, containers_for, container_env,
*, missing_secrets=None, stale=()) -> list[Finding]:
"""Run every check across the given stacklets.
Expand Down
91 changes: 91 additions & 0 deletions lib/stack/stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@
from .secrets import TomlSecretStore


def _oidc_secret_names(stacklet_id: str) -> tuple[str, str]:
"""Secret names, in the provider's namespace, for one client stacklet."""
sid = stacklet_id.upper()
return f"CLIENT_{sid}_ID", f"CLIENT_{sid}_SECRET"


def _mail_accounts_env(mail_cfg: dict, secret_lookup) -> str:
"""Build MAIL_ACCOUNTS_JSON from stack.toml [mail] + a secret lookup.

Expand Down Expand Up @@ -578,6 +584,7 @@ def env(self, stacklet_id: str) -> dict:
template_vars = self._build_template_vars()
template_vars["stacklet_id"] = stacklet_id
template_vars.update(self._own_template_vars(stacklet_id, s.get("port", 0)))
template_vars.update(self._oidc_template_vars(s))

# Render templates — warn on missing vars (typos cause silent failures)
import re
Expand Down Expand Up @@ -616,6 +623,90 @@ def env(self, stacklet_id: str) -> dict:

return rendered

# ── Single sign-on (OpenID Connect) ───────────────────────────────
#
# A client stacklet declares `[oidc]`; a provider stacklet declares
# `[oidc_provider]`. Neither names the other. The provider's hooks
# read `oidc_clients()`, register each client with the identity
# service and hand the credentials to `store_oidc_client()`. The
# client reads them as `{oidc_issuer}`, `{oidc_client_id}` and
# `{oidc_client_secret}`.
#
# Credentials are stored in the provider's secret namespace, so
# `stack destroy <provider>` deletes them and every client falls back
# to its own login on its next `stack up`.

def oidc_provider(self) -> dict | None:
"""The stacklet that declares `[oidc_provider]`, first found wins."""
for s in self.discover():
if "oidc_provider" in s.get("manifest", {}):
return s
return None

def oidc_clients(self) -> list[dict]:
"""Every stacklet with an `[oidc]` table, as a provider needs it.

`callbacks` are rendered against the client's own template vars,
so `{url}` is the client's public URL. `client_id` and
`client_secret` are what is stored now, None before the first
registration.
"""
provider = self.oidc_provider()
template_vars = self._build_template_vars()
clients = []
for s in self.discover():
oidc = s.get("manifest", {}).get("oidc")
if oidc is None:
continue
own = collections.defaultdict(str, template_vars)
own.update(self._own_template_vars(s["id"], s.get("port", 0)))
client_id, client_secret = self._oidc_credentials(provider, s["id"])
clients.append({
"stacklet": s["id"],
"name": oidc.get("name") or s["name"],
"callbacks": [c.format_map(own) for c in oidc.get("callbacks", [])],
"client_id": client_id,
"client_secret": client_secret,
})
return clients

def store_oidc_client(self, stacklet_id: str, client_id: str,
client_secret: str) -> None:
"""Store the credentials the provider issued for a client stacklet."""
provider = self.oidc_provider()
if provider is None:
raise ValueError("No stacklet declares [oidc_provider]")
id_key, secret_key = _oidc_secret_names(stacklet_id)
self.secrets.set(provider["id"], id_key, client_id)
self.secrets.set(provider["id"], secret_key, client_secret)

def _oidc_credentials(self, provider: dict | None,
stacklet_id: str) -> tuple[str | None, str | None]:
if provider is None:
return None, None
id_key, secret_key = _oidc_secret_names(stacklet_id)
return (self.secrets.get(provider["id"], id_key),
self.secrets.get(provider["id"], secret_key))

def _oidc_template_vars(self, stacklet: dict) -> dict:
"""`{oidc_*}` for one stacklet: all three set, or all three empty.

A client without both credentials gets nothing, so a compose file
can switch the login on by testing one variable.
"""
empty = {"oidc_issuer": "", "oidc_client_id": "", "oidc_client_secret": ""}
if "oidc" not in stacklet.get("manifest", {}):
return empty
provider = self.oidc_provider()
client_id, client_secret = self._oidc_credentials(provider, stacklet["id"])
if provider is None or not (client_id and client_secret):
return empty
return {
"oidc_issuer": self._public_url(provider["id"], provider.get("port", 0)),
"oidc_client_id": client_id,
"oidc_client_secret": client_secret,
}

# ── List ──────────────────────────────────────────────────────────

def list(self) -> dict:
Expand Down
16 changes: 16 additions & 0 deletions stacklets/docs/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ services:
PAPERLESS_ADMIN_USER: ${ADMIN_USER}
PAPERLESS_ADMIN_PASSWORD: ${ADMIN_PASSWORD}
PAPERLESS_URL: ${PAPERLESS_URL}
# Single sign-on through the stack's OIDC provider. PAPERLESS_APPS
# is set only when a client id exists, so without a provider none
# of this is active. The provider decides who exists, so a first
# login may create the Paperless account (auto signup). Existing
# local users are linked by verified email (EMAIL_AUTHENTICATION).
# One entry: allauth resolves the app by client id during signup,
# and a second entry with the same client id fails with a server
# error. Paperless stores the provider_id on each linked account,
# and existing accounts are linked as `pocketid`, so that id stays;
# only the button label is neutral.
PAPERLESS_APPS: ${OIDC_CLIENT_ID:+allauth.socialaccount.providers.openid_connect}
PAPERLESS_SOCIALACCOUNT_PROVIDERS: '{"openid_connect":{"EMAIL_AUTHENTICATION":true,"EMAIL_AUTHENTICATION_AUTO_CONNECT":true,"APPS":[{"provider_id":"pocketid","name":"Sign in with family account","client_id":"${OIDC_CLIENT_ID}","secret":"${OIDC_CLIENT_SECRET}","settings":{"server_url":"${OIDC_ISSUER}/.well-known/openid-configuration"}}]}}'
PAPERLESS_SOCIAL_AUTO_SIGNUP: "true"
PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS: "true"
# New accounts join 'all_documents' (permissions.py keeps the group)
PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS: all_documents
PAPERLESS_OCR_MODE: skip
# 2.x always rejected a re-uploaded identical file. 3.0 made that
# conditional on this flag: with it unset the duplicate is consumed
Expand Down
10 changes: 10 additions & 0 deletions stacklets/docs/hooks/on_install_success.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
sys.path.insert(0, str(Path(__file__).parent.parent))
from auth import ensure_api_token
from seed import seed_person_tags, seed_taxonomy
from permissions import ensure_group, share_archive

def run(ctx):
token = ensure_api_token(ctx)
Expand Down Expand Up @@ -85,3 +86,12 @@ def _seed_taxonomy(ctx, token):
seed_person_tags(url, token, ctx.users, step=ctx.step)
language = ctx.env.get("LANGUAGE", "en")
seed_taxonomy(url, token, language, step=ctx.step)

# Document access groups exist only for single sign-on installs: the
# login through the OIDC provider creates accounts that need a group.
# Installs without a provider keep Paperless's permissions untouched.
if ctx.env.get("OIDC_CLIENT_ID"):
if ensure_group(url, token, step=ctx.step):
# first enablement: existing documents become visible to
# the group once; later documents come through the workflow
share_archive(url, token, step=ctx.step)
10 changes: 10 additions & 0 deletions stacklets/docs/hooks/on_start_ready.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
sys.path.insert(0, str(Path(__file__).parent.parent))
from auth import ensure_api_token
from seed import seed_person_tags, seed_taxonomy
from permissions import ensure_group, share_archive


def run(ctx):
Expand All @@ -33,3 +34,12 @@ def run(ctx):

language = ctx.env.get("LANGUAGE", "en")
seed_taxonomy(url, token, language, step=ctx.step)

# Document access groups exist only for single sign-on installs: the
# login through the OIDC provider creates accounts that need a group.
# Installs without a provider keep Paperless's permissions untouched.
if ctx.env.get("OIDC_CLIENT_ID"):
if ensure_group(url, token, step=ctx.step):
# first enablement: existing documents become visible to
# the group once; later documents come through the workflow
share_archive(url, token, step=ctx.step)
Loading
Loading