Skip to content

Commit 0fefbaa

Browse files
committed
Add cookie auth and VPS auto deploy
1 parent 03cf04a commit 0fefbaa

31 files changed

Lines changed: 2053 additions & 301 deletions

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ LEVER_SITE_NAMES=
4444
## Frontend origin settings for the Next.js transition.
4545
FRONTEND_APP_URL=http://localhost:3000
4646
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
47+
## Leave AUTH_COOKIE_DOMAIN empty on localhost. In production, set it to
48+
## your parent domain (for example, .job-application-copilot.xyz) so the
49+
## landing and workspace subdomains share the same HttpOnly session.
50+
AUTH_COOKIE_DOMAIN=
51+
AUTH_COOKIE_SECURE=false
52+
AUTH_COOKIE_SAMESITE=lax
4753

4854
## AI-assisted workflow and the in-app assistant are login-required in the
4955
## active UI. This flag controls whether the workflow button also enforces that.

.github/workflows/ci.yml

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,49 @@ jobs:
1212

1313
steps:
1414
- name: Checkout repository
15-
uses: actions/checkout@v4
15+
uses: actions/checkout@v5
1616

1717
- name: Set up uv
18-
uses: astral-sh/setup-uv@v4
18+
uses: astral-sh/setup-uv@v6
1919
with:
2020
version: "latest"
2121

2222
- name: Set up Python
23-
uses: actions/setup-python@v5
23+
uses: actions/setup-python@v6
2424
with:
2525
python-version: "3.11"
2626

2727
- name: Sync dependencies
2828
run: uv sync --group dev --frozen
2929

3030
- name: Compile check
31-
run: uv run python -m compileall app.py src tests
31+
run: uv run python -m compileall backend src tests
3232

3333
- name: Run tests
3434
run: uv run pytest -v
35+
36+
frontend:
37+
runs-on: ubuntu-latest
38+
39+
steps:
40+
- name: Checkout repository
41+
uses: actions/checkout@v5
42+
43+
- name: Set up Node
44+
uses: actions/setup-node@v5
45+
with:
46+
node-version: "24"
47+
cache: npm
48+
cache-dependency-path: frontend/package-lock.json
49+
50+
- name: Install dependencies
51+
working-directory: frontend
52+
run: npm ci
53+
54+
- name: Lint
55+
working-directory: frontend
56+
run: npm run lint
57+
58+
- name: Build
59+
working-directory: frontend
60+
run: npm run build

.github/workflows/deploy.yml

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
name: Build & Deploy
2+
3+
on:
4+
push:
5+
branches: [main]
6+
7+
env:
8+
IMAGE: ghcr.io/leanderantony/ai_job_application_agent/api
9+
VPS_APP_DIR: /home/ubuntu/AI_Job_Application_Agent
10+
11+
jobs:
12+
build-and-push:
13+
name: Build Docker image to GHCR
14+
runs-on: ubuntu-latest
15+
permissions:
16+
contents: read
17+
packages: write
18+
19+
steps:
20+
- name: Checkout
21+
uses: actions/checkout@v5
22+
23+
- name: Log in to GHCR
24+
uses: docker/login-action@v4
25+
with:
26+
registry: ghcr.io
27+
username: ${{ github.actor }}
28+
password: ${{ github.token }}
29+
30+
- name: Set up Docker Buildx
31+
uses: docker/setup-buildx-action@v4
32+
33+
- name: Build and push
34+
uses: docker/build-push-action@v7
35+
with:
36+
context: .
37+
push: true
38+
tags: ${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.sha }}
39+
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
40+
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max
41+
42+
deploy:
43+
name: Deploy to VPS
44+
runs-on: ubuntu-latest
45+
needs: build-and-push
46+
permissions:
47+
contents: read
48+
packages: read
49+
env:
50+
GITHUB_ACTOR: ${{ github.actor }}
51+
GITHUB_TOKEN: ${{ github.token }}
52+
53+
steps:
54+
- name: Checkout
55+
uses: actions/checkout@v5
56+
57+
- name: Prepare VPS directory
58+
uses: appleboy/ssh-action@v1
59+
with:
60+
host: ${{ secrets.VPS_HOST }}
61+
username: ${{ secrets.VPS_USER }}
62+
key: ${{ secrets.VPS_SSH_KEY }}
63+
port: ${{ secrets.VPS_PORT }}
64+
script: |
65+
mkdir -p "${{ env.VPS_APP_DIR }}/deploy/vps"
66+
67+
- name: Copy deployment files
68+
uses: appleboy/scp-action@v0.1.7
69+
with:
70+
host: ${{ secrets.VPS_HOST }}
71+
username: ${{ secrets.VPS_USER }}
72+
key: ${{ secrets.VPS_SSH_KEY }}
73+
port: ${{ secrets.VPS_PORT }}
74+
source: deploy/vps/docker-compose.yml,deploy/vps/docker-compose.override.yml
75+
target: ${{ env.VPS_APP_DIR }}/deploy/vps
76+
strip_components: 2
77+
78+
- name: Deploy via SSH
79+
uses: appleboy/ssh-action@v1
80+
env:
81+
IMAGE: ${{ env.IMAGE }}
82+
GITHUB_ACTOR: ${{ env.GITHUB_ACTOR }}
83+
GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }}
84+
with:
85+
host: ${{ secrets.VPS_HOST }}
86+
username: ${{ secrets.VPS_USER }}
87+
key: ${{ secrets.VPS_SSH_KEY }}
88+
port: ${{ secrets.VPS_PORT }}
89+
envs: IMAGE,GITHUB_TOKEN,GITHUB_ACTOR
90+
script: |
91+
echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
92+
docker pull "$IMAGE:latest"
93+
cd "${{ env.VPS_APP_DIR }}/deploy/vps"
94+
docker compose -p ai_job_application_agent up -d --no-deps --no-build --force-recreate api
95+
docker logout ghcr.io
96+
97+
- name: Health check
98+
uses: appleboy/ssh-action@v1
99+
with:
100+
host: ${{ secrets.VPS_HOST }}
101+
username: ${{ secrets.VPS_USER }}
102+
key: ${{ secrets.VPS_SSH_KEY }}
103+
port: ${{ secrets.VPS_PORT }}
104+
script: |
105+
echo "Waiting for AI Job Application Agent API to be healthy..."
106+
for i in $(seq 1 12); do
107+
STATUS=$(docker inspect --format='{{.State.Health.Status}}' ai-job-application-agent-api 2>/dev/null)
108+
echo "Attempt $i: $STATUS"
109+
if [ "$STATUS" = "healthy" ]; then
110+
echo "Container is healthy"
111+
exit 0
112+
fi
113+
sleep 5
114+
done
115+
echo "Container did not become healthy in time"
116+
docker logs ai-job-application-agent-api --tail 30
117+
exit 1

backend/config.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,24 @@ class BackendSettings:
1414
cors_allowed_origins: tuple[str, ...]
1515
greenhouse_board_count: int
1616
lever_site_count: int
17+
# Auth cookie scoping. Empty domain means "host-only" (correct on
18+
# localhost, where landing+workspace share the same origin). In prod
19+
# set AUTH_COOKIE_DOMAIN=.job-application-copilot.xyz so the cookie is
20+
# valid on both the root and app.* subdomains.
21+
auth_cookie_domain: str
22+
auth_cookie_secure: bool
23+
auth_cookie_samesite: str
24+
25+
26+
def _parse_bool(value: str, default: bool) -> bool:
27+
normalized = (value or "").strip().lower()
28+
if not normalized:
29+
return default
30+
if normalized in {"1", "true", "yes", "on"}:
31+
return True
32+
if normalized in {"0", "false", "no", "off"}:
33+
return False
34+
return default
1735

1836

1937
def get_backend_settings() -> BackendSettings:
@@ -30,6 +48,20 @@ def get_backend_settings() -> BackendSettings:
3048
for origin in raw_cors_origins.split(",")
3149
if origin.strip()
3250
)
51+
52+
auth_cookie_domain = os.getenv("AUTH_COOKIE_DOMAIN", "").strip()
53+
# Default secure=true so production setups don't accidentally ship
54+
# plaintext cookies; flip AUTH_COOKIE_SECURE=false explicitly for
55+
# local HTTP dev.
56+
auth_cookie_secure = _parse_bool(
57+
os.getenv("AUTH_COOKIE_SECURE", ""),
58+
default=True,
59+
)
60+
raw_samesite = os.getenv("AUTH_COOKIE_SAMESITE", "lax").strip().lower()
61+
auth_cookie_samesite = (
62+
raw_samesite if raw_samesite in {"lax", "strict", "none"} else "lax"
63+
)
64+
3365
return BackendSettings(
3466
service_name="AI Job Application Agent Backend",
3567
service_version="0.2.0",
@@ -39,4 +71,7 @@ def get_backend_settings() -> BackendSettings:
3971
cors_allowed_origins=cors_allowed_origins,
4072
greenhouse_board_count=len(GREENHOUSE_BOARD_TOKENS),
4173
lever_site_count=len(LEVER_SITE_NAMES),
74+
auth_cookie_domain=auth_cookie_domain,
75+
auth_cookie_secure=auth_cookie_secure,
76+
auth_cookie_samesite=auth_cookie_samesite,
4277
)

backend/rate_limit.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
Bucketing strategy:
44
- Authenticated requests bucket by Supabase user-id (decoded locally
5-
from the access-token JWT, no signature verification see
5+
from the access-token JWT, no signature verification; see
66
_extract_user_id_from_jwt for why this is safe).
77
- Anonymous requests fall back to client IP.
88
- Both bucket key forms are namespaced so a forged JWT 'sub' cannot
@@ -12,7 +12,7 @@
1212
explicitly and the budgets are easy to audit in one place.
1313
1414
A RATE_LIMIT_OVERRIDE env var (e.g. "2/minute") can be set at process
15-
startup to globally override the budgets used by the test suite to
15+
startup to globally override the budgets; used by the test suite to
1616
exercise the limiter without firing dozens of real requests.
1717
"""
1818
from __future__ import annotations
@@ -29,6 +29,7 @@
2929
from slowapi.errors import RateLimitExceeded
3030
from slowapi.util import get_remote_address
3131

32+
from backend.services.auth_cookies import ACCESS_TOKEN_COOKIE
3233
from src.logging_utils import get_logger, log_event
3334

3435

@@ -45,7 +46,7 @@ def _extract_user_id_from_jwt(token: str) -> Optional[str]:
4546
still verifies the token via Supabase before performing any
4647
privileged action. A forged token cannot do real work.
4748
2. A forger of someone else's 'sub' would burn through that user's
48-
rate quota only a denial-of-service against one account, not
49+
rate quota only: a denial-of-service against one account, not
4950
privilege escalation. To bound that risk further, anonymous and
5051
authenticated buckets share a namespace prefix below so an
5152
attacker still has to forge a valid-shape JWT to even attempt it.
@@ -78,7 +79,10 @@ def resolve_rate_limit_key(request: Request) -> str:
7879
otherwise. The namespacing prevents a forged JWT bucket from
7980
sharing state with an IP bucket.
8081
"""
81-
access_token = request.headers.get("X-Auth-Access-Token", "").strip()
82+
access_token = (
83+
request.cookies.get(ACCESS_TOKEN_COOKIE, "").strip()
84+
or request.headers.get("X-Auth-Access-Token", "").strip()
85+
)
8286
user_id = _extract_user_id_from_jwt(access_token) if access_token else None
8387
if user_id:
8488
return f"user:{user_id}"
@@ -98,7 +102,7 @@ def _budget(default: str) -> str:
98102
LIMIT_HEAVY = _budget("10/minute")
99103
# Tier 2: single LLM call or external job-board fan-out.
100104
LIMIT_LLM = _budget("30/minute")
101-
# Tier 3: file parsing, artifact rendering CPU-bound but cheap.
105+
# Tier 3: file parsing, artifact rendering: CPU-bound but cheap.
102106
LIMIT_PARSE = _budget("60/minute")
103107

104108

backend/request_auth.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
11
from typing import Optional
22

3-
from fastapi import Header
3+
from fastapi import Cookie, Header
4+
5+
from backend.services.auth_cookies import (
6+
ACCESS_TOKEN_COOKIE,
7+
REFRESH_TOKEN_COOKIE,
8+
)
49

510

611
def get_optional_auth_tokens(
12+
# Primary path: HttpOnly cookies set on /auth/google/exchange and
13+
# /auth/session/restore. Frontend never sees them; the browser
14+
# attaches them automatically on every request.
15+
access_cookie: Optional[str] = Cookie(default=None, alias=ACCESS_TOKEN_COOKIE),
16+
refresh_cookie: Optional[str] = Cookie(default=None, alias=REFRESH_TOKEN_COOKIE),
17+
# Header fallback retained for the deploy window so any tab that
18+
# was open with localStorage tokens at the moment of cutover still
19+
# works until its next sign-in. Safe to remove once the rollout has
20+
# stabilized for a few days.
721
x_auth_access_token: Optional[str] = Header(default=None, alias="X-Auth-Access-Token"),
822
x_auth_refresh_token: Optional[str] = Header(default=None, alias="X-Auth-Refresh-Token"),
923
):
10-
access_token = str(x_auth_access_token or "").strip() or None
11-
refresh_token = str(x_auth_refresh_token or "").strip() or None
24+
access_token = (
25+
str(access_cookie or "").strip()
26+
or str(x_auth_access_token or "").strip()
27+
or None
28+
)
29+
refresh_token = (
30+
str(refresh_cookie or "").strip()
31+
or str(x_auth_refresh_token or "").strip()
32+
or None
33+
)
1234
return access_token, refresh_token

0 commit comments

Comments
 (0)