# Config Download Token Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add a per-config `download_token` field that allows downloading the generated ZIP without OIDC auth by POSTing the token in the request body. **Architecture:** A `download_token` (random 43-char URL-safe string) is added to the `Config` model and auto-generated on creation. The existing `POST /configs/{id}/generate` endpoint accepts an optional JSON body; if the token matches, access is granted regardless of OIDC session. A new `POST /configs/{id}/regenerate-token` endpoint (OIDC-only) replaces the token. The frontend shows the token on the Config Detail page with copy and regenerate buttons. **Tech Stack:** Python `secrets.token_urlsafe`, FastAPI optional body with `Optional` dependency, SQLAlchemy, Alembic, React + MUI. --- ### Task 1: Alembic migration — add `download_token` to configs **Files:** - Create: `backend/alembic/versions/0012_config_add_download_token.py` **Step 1: Create the migration file** ```python """add download_token to configs Revision ID: 0012 Revises: 0011 Create Date: 2026-03-01 """ import secrets from alembic import op import sqlalchemy as sa from sqlalchemy.sql import table, column revision = "0012" down_revision = "0011" branch_labels = None depends_on = None def upgrade() -> None: op.add_column( "configs", sa.Column("download_token", sa.String(64), nullable=False, server_default=""), ) # Backfill existing rows with unique tokens configs = table("configs", column("id", sa.Integer), column("download_token", sa.String(64))) conn = op.get_bind() for row in conn.execute(sa.select(configs.c.id)): conn.execute( configs.update() .where(configs.c.id == row.id) .values(download_token=secrets.token_urlsafe(32)) ) def downgrade() -> None: op.drop_column("configs", "download_token") ``` **Step 2: Verify migration runs (if you have a local DB)** ```bash cd backend && alembic upgrade head ``` Expected: no errors, `configs` table gains a `download_token` column with non-empty values. **Step 3: Commit** ```bash git add backend/alembic/versions/0012_config_add_download_token.py git commit -m "feat: migration 0012 — add download_token to configs" ``` --- ### Task 2: SQLAlchemy model — add `download_token` field **Files:** - Modify: `backend/app/models.py:21-39` **Step 1: Add the field to the `Config` model** In `backend/app/models.py`, add this import at the top alongside existing imports: ```python import secrets ``` Then add `download_token` to the `Config` class after `updated_at`: ```python download_token: Mapped[str] = mapped_column( String(64), nullable=False, default=lambda: secrets.token_urlsafe(32), ) ``` The `default=lambda: secrets.token_urlsafe(32)` ensures new configs created via SQLAlchemy get a token automatically. **Step 2: Commit** ```bash git add backend/app/models.py git commit -m "feat: add download_token field to Config model" ``` --- ### Task 3: Pydantic schemas — expose token and add request body **Files:** - Modify: `backend/app/schemas.py` **Step 1: Add `download_token` to `ConfigOut`** In `backend/app/schemas.py`, update `ConfigOut` (currently lines 29-38) to add: ```python class ConfigOut(BaseModel): id: int name: str description: str is_active: bool created_at: datetime updated_at: datetime owner_id: int download_token: str # ← add this line model_config = {"from_attributes": True} ``` **Step 2: Add `GenerateRequest` body schema** Add this new class near the bottom of `backend/app/schemas.py`, before `GenerateOut`: ```python # --- Generate request --- class GenerateRequest(BaseModel): token: Optional[str] = None ``` `Optional` is already imported at line 3. **Step 3: Add `RegenerateTokenOut` response schema** ```python class RegenerateTokenOut(BaseModel): download_token: str ``` **Step 4: Commit** ```bash git add backend/app/schemas.py git commit -m "feat: add download_token to ConfigOut and GenerateRequest schema" ``` --- ### Task 4: API — modify generate endpoint and add regenerate-token endpoint **Files:** - Modify: `backend/app/api/configs.py` **Step 1: Update imports** At the top of `backend/app/api/configs.py`, add `Optional` to the imports: ```python from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Response from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session, selectinload from app import models, schemas from app.auth import get_current_user from app.database import get_db from app.shorewall_generator import ShorewallGenerator import io import secrets ``` **Step 2: Replace the `generate_config` endpoint** Replace the entire `generate_config` function (lines 79-115) with: ```python @router.post("/{config_id}/generate") def generate_config( config_id: int, format: str = "json", body: Optional[schemas.GenerateRequest] = None, db: Session = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user), ): token = body.token if body else None # Determine access: OIDC session or matching download token if current_user is not None: # Authenticated via OIDC — enforce owner check config = ( db.query(models.Config) .filter(models.Config.id == config_id, models.Config.owner_id == current_user.id) .first() ) if not config: raise HTTPException(status_code=404, detail="Config not found") elif token: # Token auth — no owner filter, just match token config = ( db.query(models.Config) .filter(models.Config.id == config_id, models.Config.download_token == token) .first() ) if not config: raise HTTPException(status_code=401, detail="Invalid token") else: raise HTTPException(status_code=401, detail="Authentication required") # Eagerly load relationships config = ( db.query(models.Config) .options( selectinload(models.Config.zones), selectinload(models.Config.interfaces).selectinload(models.Interface.zone), selectinload(models.Config.policies).selectinload(models.Policy.src_zone), selectinload(models.Config.policies).selectinload(models.Policy.dst_zone), selectinload(models.Config.rules).selectinload(models.Rule.src_zone), selectinload(models.Config.rules).selectinload(models.Rule.dst_zone), selectinload(models.Config.snat_entries), selectinload(models.Config.host_entries).selectinload(models.Host.zone), selectinload(models.Config.params), ) .filter(models.Config.id == config_id) .first() ) generator = ShorewallGenerator(config) if format == "zip": zip_bytes = generator.as_zip() return StreamingResponse( io.BytesIO(zip_bytes), media_type="application/zip", headers={"Content-Disposition": f"attachment; filename={config.name}-shorewall.zip"}, ) return generator.as_json() ``` **Important:** `get_current_user` currently raises a 401 if no cookie is present. You need to make it return `None` instead of raising when there is no cookie, so the generate endpoint can fall back to token auth. See Task 5 for that change — do Task 5 before testing this. **Step 3: Add the `regenerate_token` endpoint** Add this after `generate_config`: ```python @router.post("/{config_id}/regenerate-token", response_model=schemas.RegenerateTokenOut) def regenerate_token( config_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ) -> dict: config = _get_config_or_404(config_id, db, current_user) config.download_token = secrets.token_urlsafe(32) db.commit() db.refresh(config) return {"download_token": config.download_token} ``` **Step 4: Commit** ```bash git add backend/app/api/configs.py git commit -m "feat: token auth on generate endpoint and regenerate-token endpoint" ``` --- ### Task 5: Auth — make `get_current_user` optional for the generate endpoint **Files:** - Modify: `backend/app/auth.py` The `generate_config` endpoint needs to accept requests with no OIDC cookie (token-only auth). Currently `get_current_user` raises 401 when the cookie is missing. Add an `optional` variant that returns `None` instead. **Step 1: Read current `get_current_user`** Open `backend/app/auth.py` and find the `get_current_user` function. It currently looks roughly like: ```python async def get_current_user( access_token: Optional[str] = Cookie(default=None), db: Session = Depends(get_db), ) -> models.User: if not access_token: raise HTTPException(status_code=401, ...) ... return user ``` **Step 2: Add `get_optional_user` that returns `None` when unauthenticated** Add this function after `get_current_user`: ```python async def get_optional_user( access_token: Optional[str] = Cookie(default=None), db: Session = Depends(get_db), ) -> Optional[models.User]: if not access_token: return None try: return await get_current_user(access_token=access_token, db=db) except HTTPException: return None ``` **Step 3: Update `generate_config` to use `get_optional_user`** In `backend/app/api/configs.py`, update the import and the dependency in `generate_config`: ```python from app.auth import get_current_user, get_optional_user ``` And change the dependency in `generate_config`: ```python current_user: Optional[models.User] = Depends(get_optional_user), ``` **Step 4: Commit** ```bash git add backend/app/auth.py backend/app/api/configs.py git commit -m "feat: add get_optional_user for unauthenticated generate access" ``` --- ### Task 6: Frontend API client — add `regenerateToken` **Files:** - Modify: `frontend/src/api.ts:31-41` **Step 1: Add `regenerateToken` to `configsApi`** In `frontend/src/api.ts`, update the `configsApi` object: ```typescript export const configsApi = { list: () => api.get('/configs'), create: (data: object) => api.post('/configs', data), get: (id: number) => api.get(`/configs/${id}`), update: (id: number, data: object) => api.put(`/configs/${id}`, data), delete: (id: number) => api.delete(`/configs/${id}`), generate: (id: number, format: 'json' | 'zip' = 'json') => api.post(`/configs/${id}/generate?format=${format}`, null, { responseType: format === 'zip' ? 'blob' : 'json', }), regenerateToken: (id: number) => api.post<{ download_token: string }>(`/configs/${id}/regenerate-token`), } ``` **Step 2: Commit** ```bash git add frontend/src/api.ts git commit -m "feat: add regenerateToken to configsApi" ``` --- ### Task 7: Frontend — display token with copy and regenerate on Config Detail **Files:** - Modify: `frontend/src/routes/ConfigDetail.tsx` **Step 1: Add state for `downloadToken`** In `ConfigDetail.tsx`, add a state variable near the other `useState` calls (around line 32): ```typescript const [downloadToken, setDownloadToken] = useState('') ``` **Step 2: Load token in `useEffect`** The existing `useEffect` already fetches the config name. Update it to also set the token: ```typescript useEffect(() => { configsApi.get(configId).then((r) => { setConfigName(r.data.name) setDownloadToken(r.data.download_token) }) // ... rest unchanged }, [configId]) ``` **Step 3: Add the token UI block** Add these MUI imports at the top of the file (alongside existing imports): ```typescript import IconButton from '@mui/material/IconButton' import InputAdornment from '@mui/material/InputAdornment' import OutlinedInput from '@mui/material/OutlinedInput' import FormControl from '@mui/material/FormControl' import InputLabel from '@mui/material/InputLabel' import Tooltip from '@mui/material/Tooltip' import ContentCopyIcon from '@mui/icons-material/ContentCopy' import RefreshIcon from '@mui/icons-material/Refresh' ``` Add a `handleRegenerate` function after `handleDelete`: ```typescript const handleRegenerate = async () => { if (!confirm('Regenerate the download token? The old token will stop working.')) return const res = await configsApi.regenerateToken(configId) setDownloadToken(res.data.download_token) } ``` In the JSX, insert this block between the breadcrumbs/generate-button row and the tabs card (i.e., after the closing `` of the top row, before the ` Download Token navigator.clipboard.writeText(downloadToken)} edge="end"> } inputProps={{ style: { fontFamily: 'monospace', fontSize: 12 } }} /> ``` **Step 4: Commit** ```bash git add frontend/src/routes/ConfigDetail.tsx git commit -m "feat: show download token with copy and regenerate on Config Detail" ``` --- ### Task 8: Manual end-to-end verification **Step 1: Rebuild and restart** ```bash docker compose up --build -d ``` **Step 2: Verify token appears in UI** Log in, open a config. You should see a "Download Token" field with a 43-character token. **Step 3: Test copy button** Click the copy icon — paste somewhere to confirm the token was copied. **Step 4: Test ZIP download with token** ```bash curl -X POST "http://localhost/api/configs/1/generate?format=zip" \ -H 'Content-Type: application/json' \ -d '{"token": ""}' \ -o /tmp/shorewall.zip file /tmp/shorewall.zip # should say: Zip archive data ``` **Step 5: Test invalid token returns 401** ```bash curl -s -o /dev/null -w "%{http_code}" -X POST \ "http://localhost/api/configs/1/generate?format=zip" \ -H 'Content-Type: application/json' \ -d '{"token": "wrong"}' # Expected: 401 ``` **Step 6: Test regenerate** Click "Regenerate" in the UI, confirm the old token stops working and the new one works. **Step 7: Commit any fixups found during testing**