swarm-nats-auth: give issued user JWTs an exp
The user JWT the callout issues carried no exp, so nats-server armed no expiry timer and a connection kept its grant until the client dropped it: a deleted or re-minted agent queue secret, a removed OIDC client or an expired access token never reached a live connection. Every issued user JWT now expires at the presented OIDC token's exp (read from the introspection answer) or an hour after issue, whichever is sooner; the agent queue-secret path has no exp of its own and gets the hour. Every queue client reconnects through an auth callback that re-reads or re-mints its credential, so the forced reconnect presents a current one. Fixes #5432
This commit is contained in:
parent
de4871cab1
commit
e405c2b098
9 changed files with 245 additions and 69 deletions
|
|
@ -55,34 +55,34 @@ of the cell says how.
|
|||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
| store path | minter | reader — pulls at runtime, holds in memory | automatic re-mint | automatic re-pull |
|
||||
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | ✅ the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ `hive-matrix-daemon` exits when the homeserver rejects its token, and a five-minute timer restarts it, which reads the store again |
|
||||
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | ✅ a secondary that didn't come up at start, or whose token the homeserver rejected, is read from the store again two minutes later, then at doubling waits up to sixteen minutes, without restarting the daemon or its other accounts; one that's up and then rejected ends its sync loop, the daemon exits and its timer restarts it |
|
||||
| `swarm/agents/<agent>/matrix-recovery/<account>` | `swarm-controller`, a random passphrase for every account listed under the agent's `matrix/`, in a five-minute pass | the agent container itself, under the certificate its hive passed in, when `hive-matrix-daemon` brings the account up | ❌ never replaced: the account's server-side key backup opens only with the passphrase it was enabled with, so the pass writes only to an empty path | ❌ read when the account comes up; an account whose passphrase lands later turns backup on at the next daemon start |
|
||||
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | ❌ `swarm-matrix-ctl` mints it once; the container keeps its copy and republishes it when the store's differs | ✅ the controller reads it on every five-minute matrix pass |
|
||||
| `swarm/controller/swarm-controller/oidc/client` | authelia, at its first boot; `swarm-secret-publish` copies it in | `swarm-controller`, under its own certificate, at start and on refusal | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ✅ read at start and held in memory; read again when authelia refuses it, at most once per backoff of 5 s doubling to 5 min while the store holds the refused value |
|
||||
| `swarm/controller/swarm-controller/forge/token` | the forge's `forgejo-swarm-controller-account`, in the `hive-forge` container, once, under the forge publisher's certificate with a write-only grant; it then revokes its earlier tokens | `swarm-controller`, under its own certificate, at start and on refusal | ❌ the forge mints it once and keeps only the token's name in the container, at `/var/lib/forgejo/swarm-controller-token-name`; deleting that file mints a new token on the unit's next start | ✅ the controller reads it at start, and in the background with backoff until it first finds it. When the forge answers 401 it reads it again and sends the request once more with the new token, at most once per backoff of 5 s doubling to 5 min while the store holds the refused value |
|
||||
| `swarm/controller/swarm-controller/grafana/token` | `swarm-bao-grafana-controller-token` on Grafana's host, under Grafana's certificate: a token for the `swarm-controller` Viewer service account, minted with Grafana's admin password | `swarm-controller`, under its own certificate, on the first `/api/alerts` request and again when Grafana refuses it | ✅ a timer starts the unit again 10 minutes after each run; it keeps a stored token Grafana accepts, and mints a new one when Grafana answers 401 or the store holds none | ✅ the controller holds the token in memory; when Grafana answers 401 it reads the store once more and retries the request |
|
||||
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config; then `hive-agent-bao-identity` in the agent container, under the leaf it holds | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ✅ `hive-agent-bao-identity` re-reads it every 10 minutes and swaps a new leaf into `/run/hive-agent-bao-identity/`, which every store login in the container presents. It reads under the leaf it holds, so it renews only while that leaf is valid |
|
||||
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | `hive-agent` in the agent container, under the agent's own certificate, held in memory — the identity it presents to the swarm queue, naming that one agent rather than its hive; `swarm-logs` and `swarm-metrics` in the agent container, under the same certificate, on every query, as the bearer they present at the log and metrics stores; `swarm-nats-auth` and `swarm-logs-auth`, each under its own certificate, and `swarm-metrics-agent-auth` under `swarm-metrics-auth`'s, to check a presented secret | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ✅ `hive-agent` reads the path before its first connect and again on every reconnect attempt, so a reconnect after a re-mint presents the new secret. An open connection keeps the secret it connected with; after a revocation the agent keeps retrying under the queue client's backoff. `swarm-logs` and `swarm-metrics` read it on every query and once more after a 401. `swarm-logs-auth` keeps granting a secret it verified for `deploy.victorialogs.authVerdictTtl` (60 s by default) without reading the path, so a replaced or deleted secret still works at the log store for up to that long; `swarm-metrics-agent-auth` likewise at the metrics store, for `deploy.victoriametrics.agentAuthVerdictTtl` |
|
||||
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer |
|
||||
| `swarm/agents/<agent>/forge/<label>` | `swarm-controller`, when an operator links an external forge account in the swarm UI | `hive-agent-forge-accounts` in the agent container, under the agent's own certificate, into `<state>/forge-<label>-token` and `forge-<label>.json` | ❌ an operator's token; replaced only by deleting it and linking the label again | ✅ the agent re-fetches on a 2-minute timer |
|
||||
| `swarm/agents/<agent>/github-token` | `swarm-controller`, when an operator links a GitHub account in the swarm UI | `hive-agent-github-token` in the agent container, under the agent's own certificate, into `<state>/github-token` | ❌ an operator's token; replaced only by deleting it and linking it again | ✅ the agent re-fetches on a 2-minute timer, and `hive-github-notify` re-reads the file on every poll |
|
||||
| `swarm/agents/<agent>/claude-credentials` | `swarm-claude-login`, under its own certificate, when an operator logs the agent in from its card in the swarm UI; a new version per login | `hive-agent` in the agent container, under the agent's own certificate, into `~/.claude/.credentials.json`, only while the agent waits for a login and only a version newer than the one it installed last | ❌ an operator's login; a relogin writes a new version | ✅ the waiting agent reads the store every 30 seconds |
|
||||
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs |
|
||||
| `swarm/hives/<hive>/forge/token` | `swarm-controller`, with the forge's admin API, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `forge::reader_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ hive-c0re reads the store on every forge read |
|
||||
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated |
|
||||
| `swarm/hives/<hive>/oidc/client` | authelia, at its first boot; `swarm-secret-publish` copies it in | hive-c0re, under the hive's own certificate, when it first connects to the queue | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ✅ read until the first connect succeeds, retried with a backoff of up to five minutes while it's missing; then held in memory and read again when authelia refuses it |
|
||||
| `swarm/services/<clientId>/oidc/client` | authelia | that service's own reader, under its own certificate — the queue responder's under `swarm-nats-auth`'s, the homeserver's under `swarm-matrix-ctl`'s, the forge's under `swarm-forge-publisher`'s, the review classifier's under `swarm-review-classifier`'s, the CI runner's, Grafana's, the log store's, the metrics store's, the forge's and the homeserver's journal forwarders' under `swarm-ci-runner`'s, `swarm-grafana-oidc`'s, `swarm-logs-auth`'s, `swarm-metrics-auth`'s, `swarm-forge-publisher`'s and `swarm-matrix-ctl`'s, authelia's journal forwarder's under `swarm-secret-publisher`'s; the bao UI's by the store's granter | must be stated | must be stated |
|
||||
| `swarm/services/ci-runner/registration-token` | `swarm-controller`, from the forge's admin API, at start and in a five-minute pass; its grant on the path is write-only | `swarm-bao-ci-runner` on the runner's host, under the runner's own certificate, into a file under the host's `/run` that only `swarm-ci-runner-register` in the runner's container loads; no other principal's policy reads it | ❌ the forge hands out one token until an admin resets it; the controller writes a reset token within five minutes | ✅ `swarm-bao-ci-runner` reads it at boot, on deploy and every ten minutes, and restarts the runner when the value changed |
|
||||
| `swarm/services/<grafana client id>/metrics/bearer` | `swarm-bao-grafana-metrics-bearer` on Grafana's host, under Grafana's certificate, when the store holds none | `swarm-metrics-auth` on the metrics store's host, under its own certificate, at most once a minute, to check the bearer Grafana's datasource presents at the store's read API, and `swarm-logs-grafana-auth` on the log store's host, under `swarm-logs-auth`'s certificate, to check the same bearer at the log store's query API | ❌ nothing re-mints it; an operator deletes it and restarts `swarm-bao-grafana-metrics-bearer` | ✅ `swarm-metrics-auth` reads it again once its copy is a minute old. ❌ Grafana reads its file copy only as it starts; the bearer unit restarts Grafana when the copy changes |
|
||||
| `swarm/services/review-classifier/anthropic-api-key` | the operator, by hand in the store's UI, field `api_key` | `swarm-review-classifier` in its container, under its own certificate, at the start of every API key run; held for that run and handed to `claude`'s environment only; no other principal's policy reads it | ❌ an operator's key; replaced by writing a new one | ✅ read on every run, so a written or removed key takes effect on the next run |
|
||||
| `swarm/services/review-classifier/acp-provider` | the operator, by hand in the store's UI, field `api_key` | `swarm-review-classifier` in its container, under its own certificate, at the start of every ACP run; held for that run and handed to `opencode`'s environment only; no other principal's policy reads it | ❌ an operator's key; replaced by writing a new one | ✅ read on every run, so a written or removed key takes effect on the next run |
|
||||
| `swarm/services/review-classifier/forge/token` | `swarm-controller`, with the forge's admin API, for its `review-classifier` account, at start and in a five-minute pass; the secret publisher's policy denies it | `swarm-controller` under its own certificate, before it decides whether to mint, and on every review it posts as the account; the classifier service's policy doesn't read it | ✅ the controller's pass re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the controller reads the store on every review it posts |
|
||||
| `swarm/services/builder/forge/token` | `swarm-controller`, with the forge's admin API, for its `builder` account (read on every config repo through the `hives` team), at start and in a five-minute pass; the secret publisher's policy denies it | `swarm-builder` in its container, under its own certificate, on every build; held for that build and handed to `git` through the environment only; `swarm-controller` under its own certificate, before it decides whether to mint | ✅ the controller's pass re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the builder reads the store on every build |
|
||||
| `swarm/services/ci-metrics/forge/token` | `swarm-controller`, with the forge's admin API, for its `ci-metrics` account (read on the Actions unit of every organisation's repos through a `ci-metrics` team in each), at start and in a five-minute pass; the secret publisher's policy denies it | `swarm-ci-metrics` on its host, under its own certificate, once per pass; `swarm-controller` under its own certificate, before it decides whether to mint | ✅ the controller's pass re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the poller reads the store on every pass |
|
||||
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | must be stated |
|
||||
| store path | minter | reader — pulls at runtime, holds in memory | automatic re-mint | automatic re-pull |
|
||||
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | ✅ the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ `hive-matrix-daemon` exits when the homeserver rejects its token, and a five-minute timer restarts it, which reads the store again |
|
||||
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | ✅ a secondary that didn't come up at start, or whose token the homeserver rejected, is read from the store again two minutes later, then at doubling waits up to sixteen minutes, without restarting the daemon or its other accounts; one that's up and then rejected ends its sync loop, the daemon exits and its timer restarts it |
|
||||
| `swarm/agents/<agent>/matrix-recovery/<account>` | `swarm-controller`, a random passphrase for every account listed under the agent's `matrix/`, in a five-minute pass | the agent container itself, under the certificate its hive passed in, when `hive-matrix-daemon` brings the account up | ❌ never replaced: the account's server-side key backup opens only with the passphrase it was enabled with, so the pass writes only to an empty path | ❌ read when the account comes up; an account whose passphrase lands later turns backup on at the next daemon start |
|
||||
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | ❌ `swarm-matrix-ctl` mints it once; the container keeps its copy and republishes it when the store's differs | ✅ the controller reads it on every five-minute matrix pass |
|
||||
| `swarm/controller/swarm-controller/oidc/client` | authelia, at its first boot; `swarm-secret-publish` copies it in | `swarm-controller`, under its own certificate, at start and on refusal | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ✅ read at start and held in memory; read again when authelia refuses it, at most once per backoff of 5 s doubling to 5 min while the store holds the refused value |
|
||||
| `swarm/controller/swarm-controller/forge/token` | the forge's `forgejo-swarm-controller-account`, in the `hive-forge` container, once, under the forge publisher's certificate with a write-only grant; it then revokes its earlier tokens | `swarm-controller`, under its own certificate, at start and on refusal | ❌ the forge mints it once and keeps only the token's name in the container, at `/var/lib/forgejo/swarm-controller-token-name`; deleting that file mints a new token on the unit's next start | ✅ the controller reads it at start, and in the background with backoff until it first finds it. When the forge answers 401 it reads it again and sends the request once more with the new token, at most once per backoff of 5 s doubling to 5 min while the store holds the refused value |
|
||||
| `swarm/controller/swarm-controller/grafana/token` | `swarm-bao-grafana-controller-token` on Grafana's host, under Grafana's certificate: a token for the `swarm-controller` Viewer service account, minted with Grafana's admin password | `swarm-controller`, under its own certificate, on the first `/api/alerts` request and again when Grafana refuses it | ✅ a timer starts the unit again 10 minutes after each run; it keeps a stored token Grafana accepts, and mints a new one when Grafana answers 401 or the store holds none | ✅ the controller holds the token in memory; when Grafana answers 401 it reads the store once more and retries the request |
|
||||
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config; then `hive-agent-bao-identity` in the agent container, under the leaf it holds | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ✅ `hive-agent-bao-identity` re-reads it every 10 minutes and swaps a new leaf into `/run/hive-agent-bao-identity/`, which every store login in the container presents. It reads under the leaf it holds, so it renews only while that leaf is valid |
|
||||
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | `hive-agent` in the agent container, under the agent's own certificate, held in memory — the identity it presents to the swarm queue, naming that one agent rather than its hive; `swarm-logs` and `swarm-metrics` in the agent container, under the same certificate, on every query, as the bearer they present at the log and metrics stores; `swarm-nats-auth` and `swarm-logs-auth`, each under its own certificate, and `swarm-metrics-agent-auth` under `swarm-metrics-auth`'s, to check a presented secret | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ✅ `hive-agent` reads the path before its first connect and again on every reconnect attempt, so a reconnect after a re-mint presents the new secret. An open connection keeps the secret it connected with for at most an hour, until the server closes it at its user JWT's expiry; after a revocation the agent keeps retrying under the queue client's backoff. `swarm-logs` and `swarm-metrics` read it on every query and once more after a 401. `swarm-logs-auth` keeps granting a secret it verified for `deploy.victorialogs.authVerdictTtl` (60 s by default) without reading the path, so a replaced or deleted secret still works at the log store for up to that long; `swarm-metrics-agent-auth` likewise at the metrics store, for `deploy.victoriametrics.agentAuthVerdictTtl` |
|
||||
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer |
|
||||
| `swarm/agents/<agent>/forge/<label>` | `swarm-controller`, when an operator links an external forge account in the swarm UI | `hive-agent-forge-accounts` in the agent container, under the agent's own certificate, into `<state>/forge-<label>-token` and `forge-<label>.json` | ❌ an operator's token; replaced only by deleting it and linking the label again | ✅ the agent re-fetches on a 2-minute timer |
|
||||
| `swarm/agents/<agent>/github-token` | `swarm-controller`, when an operator links a GitHub account in the swarm UI | `hive-agent-github-token` in the agent container, under the agent's own certificate, into `<state>/github-token` | ❌ an operator's token; replaced only by deleting it and linking it again | ✅ the agent re-fetches on a 2-minute timer, and `hive-github-notify` re-reads the file on every poll |
|
||||
| `swarm/agents/<agent>/claude-credentials` | `swarm-claude-login`, under its own certificate, when an operator logs the agent in from its card in the swarm UI; a new version per login | `hive-agent` in the agent container, under the agent's own certificate, into `~/.claude/.credentials.json`, only while the agent waits for a login and only a version newer than the one it installed last | ❌ an operator's login; a relogin writes a new version | ✅ the waiting agent reads the store every 30 seconds |
|
||||
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs |
|
||||
| `swarm/hives/<hive>/forge/token` | `swarm-controller`, with the forge's admin API, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `forge::reader_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ hive-c0re reads the store on every forge read |
|
||||
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated |
|
||||
| `swarm/hives/<hive>/oidc/client` | authelia, at its first boot; `swarm-secret-publish` copies it in | hive-c0re, under the hive's own certificate, when it first connects to the queue | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ✅ read until the first connect succeeds, retried with a backoff of up to five minutes while it's missing; then held in memory and read again when authelia refuses it |
|
||||
| `swarm/services/<clientId>/oidc/client` | authelia | that service's own reader, under its own certificate — the queue responder's under `swarm-nats-auth`'s, the homeserver's under `swarm-matrix-ctl`'s, the forge's under `swarm-forge-publisher`'s, the review classifier's under `swarm-review-classifier`'s, the CI runner's, Grafana's, the log store's, the metrics store's, the forge's and the homeserver's journal forwarders' under `swarm-ci-runner`'s, `swarm-grafana-oidc`'s, `swarm-logs-auth`'s, `swarm-metrics-auth`'s, `swarm-forge-publisher`'s and `swarm-matrix-ctl`'s, authelia's journal forwarder's under `swarm-secret-publisher`'s; the bao UI's by the store's granter | must be stated | must be stated |
|
||||
| `swarm/services/ci-runner/registration-token` | `swarm-controller`, from the forge's admin API, at start and in a five-minute pass; its grant on the path is write-only | `swarm-bao-ci-runner` on the runner's host, under the runner's own certificate, into a file under the host's `/run` that only `swarm-ci-runner-register` in the runner's container loads; no other principal's policy reads it | ❌ the forge hands out one token until an admin resets it; the controller writes a reset token within five minutes | ✅ `swarm-bao-ci-runner` reads it at boot, on deploy and every ten minutes, and restarts the runner when the value changed |
|
||||
| `swarm/services/<grafana client id>/metrics/bearer` | `swarm-bao-grafana-metrics-bearer` on Grafana's host, under Grafana's certificate, when the store holds none | `swarm-metrics-auth` on the metrics store's host, under its own certificate, at most once a minute, to check the bearer Grafana's datasource presents at the store's read API, and `swarm-logs-grafana-auth` on the log store's host, under `swarm-logs-auth`'s certificate, to check the same bearer at the log store's query API | ❌ nothing re-mints it; an operator deletes it and restarts `swarm-bao-grafana-metrics-bearer` | ✅ `swarm-metrics-auth` reads it again once its copy is a minute old. ❌ Grafana reads its file copy only as it starts; the bearer unit restarts Grafana when the copy changes |
|
||||
| `swarm/services/review-classifier/anthropic-api-key` | the operator, by hand in the store's UI, field `api_key` | `swarm-review-classifier` in its container, under its own certificate, at the start of every API key run; held for that run and handed to `claude`'s environment only; no other principal's policy reads it | ❌ an operator's key; replaced by writing a new one | ✅ read on every run, so a written or removed key takes effect on the next run |
|
||||
| `swarm/services/review-classifier/acp-provider` | the operator, by hand in the store's UI, field `api_key` | `swarm-review-classifier` in its container, under its own certificate, at the start of every ACP run; held for that run and handed to `opencode`'s environment only; no other principal's policy reads it | ❌ an operator's key; replaced by writing a new one | ✅ read on every run, so a written or removed key takes effect on the next run |
|
||||
| `swarm/services/review-classifier/forge/token` | `swarm-controller`, with the forge's admin API, for its `review-classifier` account, at start and in a five-minute pass; the secret publisher's policy denies it | `swarm-controller` under its own certificate, before it decides whether to mint, and on every review it posts as the account; the classifier service's policy doesn't read it | ✅ the controller's pass re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the controller reads the store on every review it posts |
|
||||
| `swarm/services/builder/forge/token` | `swarm-controller`, with the forge's admin API, for its `builder` account (read on every config repo through the `hives` team), at start and in a five-minute pass; the secret publisher's policy denies it | `swarm-builder` in its container, under its own certificate, on every build; held for that build and handed to `git` through the environment only; `swarm-controller` under its own certificate, before it decides whether to mint | ✅ the controller's pass re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the builder reads the store on every build |
|
||||
| `swarm/services/ci-metrics/forge/token` | `swarm-controller`, with the forge's admin API, for its `ci-metrics` account (read on the Actions unit of every organisation's repos through a `ci-metrics` team in each), at start and in a five-minute pass; the secret publisher's policy denies it | `swarm-ci-metrics` on its host, under its own certificate, once per pass; `swarm-controller` under its own certificate, before it decides whether to mint | ✅ the controller's pass re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the poller reads the store on every pass |
|
||||
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | must be stated |
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
|
|
@ -137,6 +137,11 @@ declaration lands. The credential is a bearer secret, so a soft delete would
|
|||
leave the same value readable at an older version number; the controller
|
||||
removes the path's metadata, which takes the versions with it.
|
||||
|
||||
The revocation reaches an open queue connection within an hour. The user JWT
|
||||
`swarm-nats-auth` issues at connect expires an hour after issue, nats-server
|
||||
then closes the connection, and the reconnect presents a secret the store no
|
||||
longer holds.
|
||||
|
||||
An agent recreated under the same name draws a fresh secret, because the
|
||||
mint keeps an existing value only when it finds one at that path
|
||||
(`agent_identity::mint_and_verify`, step 3) and the revocation left nothing
|
||||
|
|
|
|||
|
|
@ -73,6 +73,11 @@ timeout`**, with a test asserting the relation — a responder that answers
|
|||
It is one request's whole budget: when the `IdP` refuses the responder's own
|
||||
secret, the re-read from the store and the one retry fit inside it, or the
|
||||
request is denied and the re-read lands for the next one.
|
||||
- **Every issued user JWT expires**, at the presented OIDC token's own `exp`
|
||||
or an hour after issue, whichever is sooner. nats-server closes the
|
||||
connection at that point, and the client's reconnect comes back through
|
||||
this responder. That reconnect is what makes a deleted or re-minted
|
||||
credential reach an open connection.
|
||||
|
||||
## What the tests do and do not cover
|
||||
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@
|
|||
//!
|
||||
//! # `client_id` is modelled, and is not a second admission check
|
||||
//!
|
||||
//! `sub`/`scope`/`exp` stay unmodelled: modelling a field implies checking
|
||||
//! it. `client_id` differs in kind — `active` decides **whether** to admit,
|
||||
//! `sub`/`scope` stay unmodelled: modelling a field implies checking it.
|
||||
//! `client_id` differs in kind — `active` decides **whether** to admit,
|
||||
//! `client_id` decides **as whom**, which is what lets a grant be scoped to
|
||||
//! one hive. Admission stays one decision in one place; the identity is
|
||||
//! returned rather than a bool so "admitted but unscoped" cannot exist.
|
||||
|
|
@ -46,7 +46,7 @@ pub const INTROSPECTION_TIMEOUT: Duration = Duration::from_millis(1500);
|
|||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Answer {
|
||||
/// A 2xx: the caller, or `None` for every shape that is not an admission.
|
||||
Caller(Option<String>),
|
||||
Caller(Option<Caller>),
|
||||
/// A non-2xx, with its status: the `IdP` did not answer about the token
|
||||
/// at all. A 400 or 401 is how it refuses this responder's own
|
||||
/// credential, which `oidc_secret` re-reads and retries once before this
|
||||
|
|
@ -54,8 +54,17 @@ pub enum Answer {
|
|||
Refused(reqwest::StatusCode),
|
||||
}
|
||||
|
||||
/// The two fields this responder acts on — one per decision. Authelia returns
|
||||
/// more (`sub`, `scope`, `exp`); modelling them would imply we check them.
|
||||
/// Who an introspected token belongs to, and until when.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Caller {
|
||||
/// The client that holds the token.
|
||||
pub client_id: String,
|
||||
/// The token's `exp`, in unix seconds, when the `IdP` names one.
|
||||
pub expires: Option<i64>,
|
||||
}
|
||||
|
||||
/// The fields this responder acts on. Authelia returns more (`sub`,
|
||||
/// `scope`); modelling them would imply we check them.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct IntrospectionResponse {
|
||||
/// Whether to admit at all.
|
||||
|
|
@ -66,6 +75,11 @@ struct IntrospectionResponse {
|
|||
/// [`IntrospectionResponse::caller`].
|
||||
#[serde(default)]
|
||||
client_id: Option<String>,
|
||||
/// When the token expires. It admits nothing (`active` alone does) and
|
||||
/// only caps the issued user JWT's `exp`, at which nats-server closes the
|
||||
/// connection.
|
||||
#[serde(default)]
|
||||
exp: Option<i64>,
|
||||
}
|
||||
|
||||
impl IntrospectionResponse {
|
||||
|
|
@ -80,17 +94,21 @@ impl IntrospectionResponse {
|
|||
/// empty component in a subject the grant is scoped to, and a scope
|
||||
/// assembled from a blank is not a narrower permission, it is a different
|
||||
/// one nobody reviewed.
|
||||
fn caller(&self) -> Option<&str> {
|
||||
fn caller(&self) -> Option<Caller> {
|
||||
if !self.active {
|
||||
return None;
|
||||
}
|
||||
self.client_id.as_deref().filter(|id| !id.is_empty())
|
||||
let client_id = self.client_id.as_deref().filter(|id| !id.is_empty())?;
|
||||
Some(Caller {
|
||||
client_id: client_id.to_owned(),
|
||||
expires: self.exp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the `IdP` whether `token` is currently valid, and as whom.
|
||||
///
|
||||
/// Returns `Ok(Answer::Caller(Some(client_id)))` **only** on a 2xx whose
|
||||
/// Returns `Ok(Answer::Caller(Some(caller)))` **only** on a 2xx whose
|
||||
/// body says `active: true` and names the client. Every other 2xx is
|
||||
/// `Ok(Answer::Caller(None))` with the reason logged, a non-2xx is
|
||||
/// `Ok(Answer::Refused(_))`, and `Err` is a call that could not be made at all
|
||||
|
|
@ -139,7 +157,7 @@ pub async fn identify_caller(
|
|||
// rejected for no visible reason.
|
||||
tracing::warn!("introspection said active but named no client; denying");
|
||||
}
|
||||
Ok(Answer::Caller(body.caller().map(str::to_owned)))
|
||||
Ok(Answer::Caller(body.caller()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -177,7 +195,20 @@ mod tests {
|
|||
fn an_active_token_is_admitted_as_the_client_that_holds_it() {
|
||||
assert_eq!(
|
||||
parse(r#"{"active":true,"client_id":"hive-alpha","scope":"x"}"#).caller(),
|
||||
Some("hive-alpha")
|
||||
Some(Caller {
|
||||
client_id: "hive-alpha".to_owned(),
|
||||
expires: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_admitted_caller_carries_the_tokens_exp() {
|
||||
assert_eq!(
|
||||
parse(r#"{"active":true,"client_id":"hive-alpha","exp":1700000000}"#)
|
||||
.caller()
|
||||
.and_then(|c| c.expires),
|
||||
Some(1_700_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -278,7 +278,9 @@ async fn serve(args: &Args) -> anyhow::Result<()> {
|
|||
// normal thing for a client to attempt and an abnormal thing to
|
||||
// grant. Neither the store nor introspection is reached until
|
||||
// something was presented.
|
||||
let (caller, permissions) = match req
|
||||
// `expires` is the presented credential's own expiry, when it has one:
|
||||
// an agent's queue secret has none.
|
||||
let (caller, permissions, expires) = match req
|
||||
.connect_opts
|
||||
.auth_token
|
||||
.as_deref()
|
||||
|
|
@ -289,15 +291,16 @@ async fn serve(args: &Args) -> anyhow::Result<()> {
|
|||
Some(agent_token::Presented::Agent(token)) => (
|
||||
Some(format!("agent:{}", token.agent)),
|
||||
agent_token::authorize(&policy, &store, &token).await,
|
||||
None,
|
||||
),
|
||||
Some(agent_token::Presented::Malformed(e)) => {
|
||||
tracing::warn!(error = %e, "malformed agent token; denying");
|
||||
(None, None)
|
||||
(None, None, None)
|
||||
}
|
||||
Some(agent_token::Presented::Bearer(token)) => {
|
||||
introspected(&policy, &http, args, &client_secret, token).await
|
||||
}
|
||||
None => (None, None),
|
||||
None => (None, None, None),
|
||||
};
|
||||
// The caller is an identifier, not a credential: a client id, or
|
||||
// `agent:<name>` for an agent token. One line per auth request, a connect
|
||||
|
|
@ -326,6 +329,7 @@ async fn serve(args: &Args) -> anyhow::Result<()> {
|
|||
&req.server_id.id,
|
||||
&req.user_nkey,
|
||||
permissions,
|
||||
expires,
|
||||
),
|
||||
None => respond::deny(&issuer, &req.server_id.id, &req.user_nkey),
|
||||
};
|
||||
|
|
@ -439,8 +443,9 @@ async fn ensure_callout_keys(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// The OIDC path: who the `IdP` says presented `token`, and what the policy
|
||||
/// grants that client. `None` permissions is a denial.
|
||||
/// The OIDC path: who the `IdP` says presented `token`, what the policy
|
||||
/// grants that client, and when the token expires. `None` permissions is a
|
||||
/// denial.
|
||||
///
|
||||
/// The caller is an identity or nothing — see `introspect`'s module docs.
|
||||
/// There is no "admitted, identity unknown" branch to write here because
|
||||
|
|
@ -451,7 +456,7 @@ async fn introspected(
|
|||
args: &Args,
|
||||
client_secret: &oidc_secret::OidcSecret,
|
||||
token: &str,
|
||||
) -> (Option<String>, Option<policy::Permissions>) {
|
||||
) -> (Option<String>, Option<policy::Permissions>, Option<i64>) {
|
||||
let deadline = tokio::time::Instant::now() + introspect::INTROSPECTION_TIMEOUT;
|
||||
let caller = client_secret
|
||||
.introspect(deadline, |secret| async move {
|
||||
|
|
@ -473,6 +478,7 @@ async fn introspected(
|
|||
tracing::warn!(error = ?e, "introspection failed; denying");
|
||||
None
|
||||
});
|
||||
let (caller, expires) = caller.map_or((None, None), |c| (Some(c.client_id), c.expires));
|
||||
// Admission said who; the policy says what. A caller the `IdP`
|
||||
// vouches for but no rule matches is denied — see `policy`'s module
|
||||
// docs for why that is deny and not "connect with nothing".
|
||||
|
|
@ -486,7 +492,7 @@ async fn introspected(
|
|||
"authenticated client matches no policy rule; denying"
|
||||
);
|
||||
}
|
||||
(caller, permissions)
|
||||
(caller, permissions, expires)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use swarm_secret_client::client::{DEFAULT_CERT_MOUNT, Settings};
|
|||
use swarm_secret_client::path::{Kind, principal_prefix};
|
||||
use tokio::time::Instant;
|
||||
|
||||
use crate::introspect::Answer;
|
||||
use crate::introspect::{Answer, Caller};
|
||||
use crate::keystore::KeyStore;
|
||||
|
||||
/// The path under the store's KV mount holding `client_id`'s secret.
|
||||
|
|
@ -99,7 +99,7 @@ impl OidcSecret {
|
|||
///
|
||||
/// # Errors
|
||||
/// When an attempt could not be made at all; the caller denies.
|
||||
pub async fn introspect<F, Fut>(&self, deadline: Instant, attempt: F) -> Result<Option<String>>
|
||||
pub async fn introspect<F, Fut>(&self, deadline: Instant, attempt: F) -> Result<Option<Caller>>
|
||||
where
|
||||
F: Fn(String) -> Fut,
|
||||
Fut: Future<Output = Result<Answer>>,
|
||||
|
|
@ -165,7 +165,10 @@ mod tests {
|
|||
move |secret| {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
std::future::ready(Ok(if secret == registered {
|
||||
Answer::Caller(Some("hive-alpha".to_owned()))
|
||||
Answer::Caller(Some(Caller {
|
||||
client_id: "hive-alpha".to_owned(),
|
||||
expires: None,
|
||||
}))
|
||||
} else {
|
||||
Answer::Refused(reqwest::StatusCode::UNAUTHORIZED)
|
||||
}))
|
||||
|
|
@ -203,7 +206,7 @@ mod tests {
|
|||
.introspect(budget(), idp("old", &calls))
|
||||
.await
|
||||
.expect("answered");
|
||||
assert_eq!(caller.as_deref(), Some("hive-alpha"));
|
||||
assert_eq!(caller.map(|c| c.client_id).as_deref(), Some("hive-alpha"));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(reads.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
|
@ -219,7 +222,7 @@ mod tests {
|
|||
.introspect(budget(), idp("new", &calls))
|
||||
.await
|
||||
.expect("answered");
|
||||
assert_eq!(caller.as_deref(), Some("hive-alpha"));
|
||||
assert_eq!(caller.map(|c| c.client_id).as_deref(), Some("hive-alpha"));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(reads.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
|
@ -279,7 +282,7 @@ mod tests {
|
|||
.introspect(budget(), idp("new", &calls))
|
||||
.await
|
||||
.expect("answered");
|
||||
assert_eq!(caller.as_deref(), Some("hive-alpha"));
|
||||
assert_eq!(caller.map(|c| c.client_id).as_deref(), Some("hive-alpha"));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 5);
|
||||
assert_eq!(reads.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
|
@ -307,7 +310,7 @@ mod tests {
|
|||
.introspect(budget(), idp("new", &calls))
|
||||
.await
|
||||
.expect("answered");
|
||||
assert_eq!(next.as_deref(), Some("hive-alpha"));
|
||||
assert_eq!(next.map(|c| c.client_id).as_deref(), Some("hive-alpha"));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(reads.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
|
@ -326,7 +329,7 @@ mod tests {
|
|||
if headers.get(AUTHORIZATION).and_then(|v| v.to_str().ok()) == Some(&*expected) {
|
||||
(
|
||||
StatusCode::OK,
|
||||
r#"{"active":true,"client_id":"hive-alpha"}"#,
|
||||
r#"{"active":true,"client_id":"hive-alpha","exp":1700000000}"#,
|
||||
)
|
||||
} else {
|
||||
(StatusCode::UNAUTHORIZED, "")
|
||||
|
|
@ -369,7 +372,13 @@ mod tests {
|
|||
})
|
||||
.await
|
||||
.expect("answered");
|
||||
assert_eq!(caller.as_deref(), Some("hive-alpha"));
|
||||
assert_eq!(
|
||||
caller,
|
||||
Some(Caller {
|
||||
client_id: "hive-alpha".to_owned(),
|
||||
expires: Some(1_700_000_000),
|
||||
})
|
||||
);
|
||||
assert_eq!(reads.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,11 +35,23 @@ use sha2::{Digest, Sha256};
|
|||
/// equality test would catch it drifting.
|
||||
const JWT_HEADER: &str = r#"{"typ":"JWT","alg":"ed25519-nkey"}"#;
|
||||
|
||||
/// The longest an issued user JWT lives. At its `exp` nats-server closes the
|
||||
/// connection and the client reconnects through this responder, so this is
|
||||
/// how long a deleted or re-minted queue secret keeps a live connection.
|
||||
/// An hour matches the lifetime of the OIDC access tokens the other clients
|
||||
/// present, which caps their JWTs anyway; the cost is one reconnect per
|
||||
/// client per hour.
|
||||
const MAX_USER_JWT_LIFETIME: std::time::Duration = std::time::Duration::from_hours(1);
|
||||
|
||||
/// A NATS JWT claim set, with `aud` — which the callout response needs and
|
||||
/// `nats-jwt`'s own `Claims` lacks.
|
||||
#[derive(Serialize)]
|
||||
struct Claims<T: Serialize> {
|
||||
iat: i64,
|
||||
/// Unix seconds. nats-server closes a connection admitted with this JWT
|
||||
/// when it passes.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
exp: Option<i64>,
|
||||
iss: String,
|
||||
jti: String,
|
||||
sub: String,
|
||||
|
|
@ -108,6 +120,7 @@ fn response(
|
|||
sign(
|
||||
Claims {
|
||||
iat: now,
|
||||
exp: None,
|
||||
iss: issuer.public_key(),
|
||||
jti: String::new(),
|
||||
sub: user_nkey.to_owned(),
|
||||
|
|
@ -205,16 +218,25 @@ impl ResponsePermission {
|
|||
/// `account` is the account **name** from the server's `accounts` block (the
|
||||
/// module's `clientAccount`), not a public key — in config mode the server
|
||||
/// resolves `aud` against its own config rather than against a key.
|
||||
///
|
||||
/// It expires at `credential_expires` (the presented credential's own
|
||||
/// expiry, unix seconds) or [`MAX_USER_JWT_LIFETIME`] after `now`, whichever
|
||||
/// is sooner.
|
||||
fn user_jwt(
|
||||
now: i64,
|
||||
issuer: &KeyPair,
|
||||
account: &str,
|
||||
user_nkey: &str,
|
||||
permissions: &crate::policy::Permissions,
|
||||
credential_expires: Option<i64>,
|
||||
) -> String {
|
||||
let latest = now.saturating_add(
|
||||
i64::try_from(MAX_USER_JWT_LIFETIME.as_secs()).expect("an hour fits in an i64"),
|
||||
);
|
||||
sign(
|
||||
Claims {
|
||||
iat: now,
|
||||
exp: Some(credential_expires.map_or(latest, |t| t.min(latest))),
|
||||
iss: issuer.public_key(),
|
||||
jti: String::new(),
|
||||
sub: user_nkey.to_owned(),
|
||||
|
|
@ -242,7 +264,8 @@ fn user_jwt(
|
|||
}
|
||||
|
||||
/// Grant: mint a user JWT placing the client in `account`, scoped to
|
||||
/// `permissions`, and wrap it.
|
||||
/// `permissions` and expiring no later than `credential_expires` (see
|
||||
/// [`user_jwt`]), and wrap it.
|
||||
///
|
||||
/// Taking the permissions by value rather than defaulting them is the point:
|
||||
/// there is no way to call this and get an unscoped grant by omission, so a
|
||||
|
|
@ -253,9 +276,17 @@ pub fn grant(
|
|||
server_id: &str,
|
||||
user_nkey: &str,
|
||||
permissions: &crate::policy::Permissions,
|
||||
credential_expires: Option<i64>,
|
||||
) -> String {
|
||||
let now = now_secs();
|
||||
let jwt = user_jwt(now, issuer, account, user_nkey, permissions);
|
||||
let jwt = user_jwt(
|
||||
now,
|
||||
issuer,
|
||||
account,
|
||||
user_nkey,
|
||||
permissions,
|
||||
credential_expires,
|
||||
);
|
||||
response(now, issuer, server_id, user_nkey, Ok(jwt))
|
||||
}
|
||||
|
||||
|
|
@ -337,6 +368,7 @@ mod tests {
|
|||
let mine = sign(
|
||||
Claims {
|
||||
iat: decoded["iat"].as_i64().expect("iat"),
|
||||
exp: None,
|
||||
iss: account.public_key(),
|
||||
jti: String::new(),
|
||||
sub: user.public_key(),
|
||||
|
|
@ -383,13 +415,68 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// The `exp` of a user JWT minted at `now` for a credential expiring at
|
||||
/// `credential_expires`.
|
||||
fn user_exp(now: i64, credential_expires: Option<i64>) -> serde_json::Value {
|
||||
let account = KeyPair::new_account();
|
||||
let jwt = user_jwt(
|
||||
now,
|
||||
&account,
|
||||
"APP",
|
||||
"UCLIENT",
|
||||
&perms(),
|
||||
credential_expires,
|
||||
);
|
||||
decode_claims(&jwt)["exp"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_issued_user_jwt_expires_with_a_credential_that_expires_sooner() {
|
||||
let now = 1_700_000_000;
|
||||
assert_eq!(user_exp(now, Some(now + 60)), now + 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_issued_user_jwt_expires_after_the_max_lifetime_otherwise() {
|
||||
let now = 1_700_000_000;
|
||||
let latest = now + 60 * 60;
|
||||
assert_eq!(user_exp(now, None), latest);
|
||||
assert_eq!(user_exp(now, Some(latest + 1)), latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_granted_user_jwt_carries_an_exp_after_its_iat() {
|
||||
let account = KeyPair::new_account();
|
||||
let wrapper = decode_claims(&grant(
|
||||
&account,
|
||||
"APP",
|
||||
"NSERVER",
|
||||
"UCLIENT",
|
||||
&perms(),
|
||||
None,
|
||||
));
|
||||
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
|
||||
let (iat, exp) = (user["iat"].as_i64(), user["exp"].as_i64());
|
||||
assert!(
|
||||
iat.zip(exp).is_some_and(|(iat, exp)| exp > iat),
|
||||
"iat {iat:?}, exp {exp:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_issued_user_jwt_carries_the_permissions_it_was_given() {
|
||||
// The scoping is only real if it survives into the minted token. A
|
||||
// policy that computes the right subjects and a grant that drops them
|
||||
// look identical from every test that stops at the policy.
|
||||
let account = KeyPair::new_account();
|
||||
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &perms()));
|
||||
let wrapper = decode_claims(&grant(
|
||||
&account,
|
||||
"APP",
|
||||
"NSERVER",
|
||||
"UCLIENT",
|
||||
&perms(),
|
||||
None,
|
||||
));
|
||||
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
|
||||
assert_eq!(user["nats"]["pub"]["allow"][0], "$KV.hive-status.alpha");
|
||||
// An empty or absent allow-list is NATS' "everything": the one shape
|
||||
|
|
@ -410,14 +497,21 @@ mod tests {
|
|||
subscribe: Some(vec!["_INBOX.agent.atlas.>".to_owned()]),
|
||||
..perms()
|
||||
};
|
||||
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &scoped));
|
||||
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &scoped, None));
|
||||
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
|
||||
assert_eq!(
|
||||
user["nats"]["sub"]["allow"],
|
||||
serde_json::json!(["_INBOX.agent.atlas.>"])
|
||||
);
|
||||
|
||||
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &perms()));
|
||||
let wrapper = decode_claims(&grant(
|
||||
&account,
|
||||
"APP",
|
||||
"NSERVER",
|
||||
"UCLIENT",
|
||||
&perms(),
|
||||
None,
|
||||
));
|
||||
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
|
||||
assert!(user["nats"]["sub"].is_null(), "{}", user["nats"]);
|
||||
}
|
||||
|
|
@ -429,7 +523,9 @@ mod tests {
|
|||
responses: Some(std::time::Duration::from_mins(31)),
|
||||
..perms()
|
||||
};
|
||||
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &answering));
|
||||
let wrapper = decode_claims(&grant(
|
||||
&account, "APP", "NSERVER", "UCLIENT", &answering, None,
|
||||
));
|
||||
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
|
||||
// The names and units nats-server's `jwt/v2` reads: `max` messages,
|
||||
// `ttl` as a Go duration in nanoseconds.
|
||||
|
|
@ -438,7 +534,14 @@ mod tests {
|
|||
serde_json::json!({ "max": 1, "ttl": 31 * 60 * 1_000_000_000_i64 })
|
||||
);
|
||||
|
||||
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &perms()));
|
||||
let wrapper = decode_claims(&grant(
|
||||
&account,
|
||||
"APP",
|
||||
"NSERVER",
|
||||
"UCLIENT",
|
||||
&perms(),
|
||||
None,
|
||||
));
|
||||
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
|
||||
assert!(user["nats"]["resp"].is_null(), "{}", user["nats"]);
|
||||
}
|
||||
|
|
@ -446,7 +549,14 @@ mod tests {
|
|||
#[test]
|
||||
fn a_grant_carries_a_user_jwt_and_no_error() {
|
||||
let account = KeyPair::new_account();
|
||||
let claims = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &perms()));
|
||||
let claims = decode_claims(&grant(
|
||||
&account,
|
||||
"APP",
|
||||
"NSERVER",
|
||||
"UCLIENT",
|
||||
&perms(),
|
||||
None,
|
||||
));
|
||||
assert!(claims["nats"]["error"].is_null());
|
||||
assert!(
|
||||
claims["nats"]["jwt"]
|
||||
|
|
@ -470,7 +580,14 @@ mod tests {
|
|||
#[test]
|
||||
fn the_issued_user_jwt_names_its_account_by_aud_and_sets_no_issuer_account() {
|
||||
let account = KeyPair::new_account();
|
||||
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &perms()));
|
||||
let wrapper = decode_claims(&grant(
|
||||
&account,
|
||||
"APP",
|
||||
"NSERVER",
|
||||
"UCLIENT",
|
||||
&perms(),
|
||||
None,
|
||||
));
|
||||
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
|
||||
|
||||
// In server-config mode the account is named by `aud`...
|
||||
|
|
|
|||
|
|
@ -254,6 +254,7 @@ pub(crate) mod tests {
|
|||
&req.server_id.id,
|
||||
&req.user_nkey,
|
||||
&permissions,
|
||||
None,
|
||||
);
|
||||
let reply = seal(&own, &server_key, token.as_bytes()).expect("sealed");
|
||||
assert_ne!(reply, token.as_bytes(), "the reply leaves sealed");
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ unix socket.
|
|||
## The two properties that constrain the code
|
||||
|
||||
**A token expires.** Authelia issues `client_credentials` access tokens with
|
||||
`expires_in: 3599`. Authentication happens at CONNECT, so a long-lived
|
||||
connection is fine — but a _reconnect_ an hour later needs a token minted an
|
||||
hour later.
|
||||
`expires_in: 3599`. Authentication happens at CONNECT, and the user JWT
|
||||
`swarm-nats-auth` issues expires no later than the token does, so the server
|
||||
closes the connection within the hour and the _reconnect_ needs a token
|
||||
minted after it.
|
||||
|
||||
**The refresh therefore lives in the auth callback, not in a timer.**
|
||||
`async-nats` invokes it per connection attempt, so there is no window in which
|
||||
|
|
|
|||
|
|
@ -16,9 +16,10 @@
|
|||
//! Two things about that shape drive everything here:
|
||||
//!
|
||||
//! - **A token expires.** Authelia issues `client_credentials` access tokens
|
||||
//! with `expires_in: 3599`. Authentication happens at CONNECT, so a
|
||||
//! long-lived connection is fine — but a *reconnect* an hour later needs a
|
||||
//! token that was minted an hour later.
|
||||
//! with `expires_in: 3599`. Authentication happens at CONNECT, and the user
|
||||
//! JWT `swarm-nats-auth` issues expires no later than the token does, so
|
||||
//! the server closes the connection within the hour and the *reconnect*
|
||||
//! needs a token minted after it.
|
||||
//! - **`async-nats` re-runs an auth callback per connection attempt** (it is
|
||||
//! handed that attempt's nonce). So the refresh belongs in the callback and
|
||||
//! not in a timer: there is no window in which the client presents a token
|
||||
|
|
|
|||
Loading…
Reference in a new issue