# Sign-in and secrets

> Reference · https://spoletta.com/docs/sign-in

Once an instance runs, with [Docker](https://spoletta.com/docs/install-docker.md), [Podman](https://spoletta.com/docs/install-podman.md) or [Kubernetes](https://spoletta.com/docs/install-kubernetes.md), this page covers letting people sign in and keeping the secrets it holds sealed, including rotating the key that seals them.

> **Only on this machine until people sign in:** Without sign-in, anyone who reaches the webapp can do anything in it, running scripts on the worker included. So until sign-in is set up, keep the webapp to this machine: compose publishes it and MySQL on the host's loopback only, and in Kubernetes you reach it with `kubectl port-forward`. Once people sign in, set `PUBLISH_HOST=0.0.0.0` with compose, or add an Ingress in Kubernetes, to reach it from other machines.

## Signing in

People sign in over OpenID Connect with your identity provider, with Google, or with either: with both set up, signing in asks which, and a person is the same user whichever they pick. Register `<your address>/api/auth/callback` as each client's redirect URI, and set:

| Setting | What it is |
| --- | --- |
| `OIDC_ISSUER` | Your provider's address. Without it or Google, nobody signs in. |
| `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | The client you registered with your provider. |
| `OIDC_NAME` | What to call your provider when signing in asks which; its host name otherwise. |
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | A web application's OAuth client in the Google Cloud console, for signing in with Google. |
| `ADMIN_EMAILS` | Who is an admin, comma-separated. |
| `OIDC_ALLOWED_DOMAINS` | Email domains anyone at may sign in, with either provider. Others need an admin to add them. |
| `PUBLIC_URL` | The address people reach Spoletta by, when it differs from what the browser sees. |

> **Who Google vouches for:** Anyone can make a Google account with any email, and keep it after leaving your company. So Spoletta takes Google's word only for Gmail addresses and for accounts of the Google Workspace at the email's own domain, admins included. If your company isn't on Google Workspace, people with company addresses sign in with your own provider instead.

## Sealing secrets

The instance keeps secrets for others in its database: the [model keys](https://spoletta.com/docs/admin.md#model-keys) admins save, and the tokens of each person's [MCP connections](https://spoletta.com/docs/admin.md#mcp-servers). With a secret key set, each is sealed (encrypted with AES-256-GCM) and bound to the row it is kept in, so a copy of the database, a backup or a dump doesn't give them away.

1.  **Make a key**
    
    At least 32 characters:
    
    ```sh
    openssl rand -base64 32
    ```
    
2.  **Give it to the webapp**
    
    As `SECRET_KEY`, or in a file whose path is in `SECRET_KEY_FILE`, as with secret stores that mount secrets as files, such as the Vault Agent Injector or the Secrets Store CSI driver.
    
3.  **Restart it**
    
    On starting, the webapp seals the secrets it kept unsealed before, and checks it can open every sealed one.
    

> **Keep the key with your backups:** Once secrets are sealed, the webapp won't start without the key that sealed them, rather than fail every agent step later, and the secrets in a backup can't be opened without it. Without any key, secrets are kept unsealed and the webapp warns about it when it starts.

## Rotating the secret key

`SECRET_KEY_SECONDARY` (or `SECRET_KEY_SECONDARY_FILE`) holds the key coming in. A webapp that has it opens secrets sealed with either key and seals new ones with the new key, but leaves the existing ones as they are, so webapps not yet restarted keep working. `webapp seal-secrets` then seals every secret with the new key and exits. Setting a key to nothing, or emptying its file, is the same as leaving it out.

1.  **Add the new key as the secondary, and restart every webapp**
    
    While some webapps have restarted and others haven't, what a restarted one saves, such as a new model key or a refreshed MCP token, can't be opened by the others until they restart too. One replica, or a Kubernetes `strategy: Recreate`, avoids that. Let every webapp restart before the next step.
    
2.  **Seal everything with the new key**
    
    Run `webapp seal-secrets` once, with the webapp's image and the same settings (database and both keys). It logs *all N secrets are sealed with SECRET\_KEY\_SECONDARY*. If some secret changed while it ran, it exits with 1. Run it again.
    
3.  **Make the new key the primary**
    
    Set `SECRET_KEY` to the new key and `SECRET_KEY_SECONDARY` to nothing, and restart again. Should a secret still be sealed with the old key, the new webapps refuse to start and the old ones go on serving: put the old key back as the secondary and repeat step 2.
    

Keep the old key as long as the backups taken before the rotation.

### In Kubernetes

Restart the webapps with `kubectl rollout restart deployment/spoletta-webapp` and wait with `kubectl rollout status`. Step 2 is a Job:

seal-secrets-job.yaml:

```yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: spoletta-seal-secrets
spec:
  backoffLimit: 3
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: seal-secrets
          image: <the webapp's image>
          args: ["webapp", "seal-secrets"]
          envFrom:
            - secretRef:
                name: spoletta
          # plus DB_HOST, DB_NAME and DB_USER, as the webapp's Deployment has them
```

```sh
kubectl apply -f seal-secrets-job.yaml
kubectl wait --for=condition=complete job/spoletta-seal-secrets --timeout=5m
kubectl logs job/spoletta-seal-secrets
```

Where you change the keys depends on where the webapp's settings come from:

-   **A Secret kept by hand**: patch it.
    
    ```sh
    NEW=$(openssl rand -base64 32)
    # step 1
    kubectl patch secret spoletta --type merge -p "{"stringData":{"SECRET_KEY_SECONDARY":"$NEW"}}"
    # step 3
    kubectl patch secret spoletta --type merge -p "{"stringData":{"SECRET_KEY":"$NEW","SECRET_KEY_SECONDARY":""}}"
    ```
    
-   **A Secret a secrets manager syncs**, such as the Vault Secrets Operator or External Secrets: change the keys in the store, since the manager would undo a patch, and wait for the Secret to sync before restarting. With Vault's KV store, for example:
    
    ```sh
    # step 1
    vault kv patch secret/spoletta SECRET_KEY_SECONDARY="$NEW"
    # step 3
    vault kv patch secret/spoletta SECRET_KEY="$NEW" SECRET_KEY_SECONDARY=""
    ```
    
-   **Keys mounted as files**, as the Vault Agent Injector or the Secrets Store CSI driver do: change them in the store and point the webapp at them with `SECRET_KEY_FILE` and `SECRET_KEY_SECONDARY_FILE`. The webapp reads them only when it starts, so restart it once the files change. The Job needs the same files mounted.

### With compose

Set the keys in `.env`, restart with `docker compose up -d webapp`, and run step 2 with `docker compose run --rm webapp webapp seal-secrets`. With Podman, the same with `podman compose`.
