diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..31eee98 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,48 @@ +# deploy/ + +Holds the **live secrets** (`deploy/.env`) and one-command scripts to build and run the stack against them. `deploy/.env` is git-ignored — it is never committed. + +## deploy/.env + +Required keys (see `../.env.example` for the template): + +``` +POSTGRES_PASSWORD=... +GOOGLE_CLIENT_ID=... +GOOGLE_CLIENT_SECRET=... +AI_MODE=Disabled # Disabled | LocalOllama | CloudOpenAi +FRONTEND_ORIGIN=http://localhost:8081 +``` + +## Commands + +Windows (PowerShell), from the repo root: + +```powershell +./deploy/up.ps1 # build + start (detached) +./deploy/up.ps1 -Foreground # stream logs instead of detaching +./deploy/up.ps1 -Proxy # also run the top-level nginx (everything on :80) +./deploy/logs.ps1 api # tail a service's logs +./deploy/down.ps1 # stop +./deploy/down.ps1 -Volumes # stop and wipe the DB + key volumes +``` + +Linux / Ubuntu production target: + +```bash +./deploy/up.sh # build + start (detached) +./deploy/up.sh --foreground +./deploy/up.sh --proxy +``` + +Both scripts validate that `deploy/.env` exists and that the required keys are non-blank before invoking Docker, so a misconfigured env fails fast with a clear message instead of a half-started stack. + +## What it runs + +`docker compose --env-file deploy/.env up --build` — Postgres, the API (auto-applies EF migrations on boot), and the frontend. The `--env-file` flag feeds the `${...}` variables in `docker-compose.yml`. + +After it's up: +- Frontend — http://localhost:8081 +- API / Swagger — http://localhost:8080/swagger + +Reminder: the Google OAuth redirect URI for this layout is `http://localhost:8081/signin-google` (see `../docs/GOOGLE_OAUTH_SETUP.md`). diff --git a/deploy/down.ps1 b/deploy/down.ps1 new file mode 100644 index 0000000..deb704e --- /dev/null +++ b/deploy/down.ps1 @@ -0,0 +1,19 @@ +<# +.SYNOPSIS + Stop the InboxIntel stack. Use -Volumes to also drop the database + key data. +.EXAMPLE + ./deploy/down.ps1 + ./deploy/down.ps1 -Volumes +#> +param([switch]$Volumes) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$envFile = Join-Path $PSScriptRoot '.env' + +$composeArgs = @('compose', '--env-file', $envFile, 'down') +if ($Volumes) { $composeArgs += '--volumes' } + +Push-Location $root +try { & docker @composeArgs } +finally { Pop-Location } diff --git a/deploy/logs.ps1 b/deploy/logs.ps1 new file mode 100644 index 0000000..25f5f79 --- /dev/null +++ b/deploy/logs.ps1 @@ -0,0 +1,19 @@ +<# +.SYNOPSIS + Tail logs for the stack (or a single service, e.g. api / frontend / postgres). +.EXAMPLE + ./deploy/logs.ps1 # all services + ./deploy/logs.ps1 api # just the API container +#> +param([string]$Service) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$envFile = Join-Path $PSScriptRoot '.env' + +$composeArgs = @('compose', '--env-file', $envFile, 'logs', '-f', '--tail', '200') +if ($Service) { $composeArgs += $Service } + +Push-Location $root +try { & docker @composeArgs } +finally { Pop-Location } diff --git a/deploy/up.ps1 b/deploy/up.ps1 new file mode 100644 index 0000000..a6dfd5b --- /dev/null +++ b/deploy/up.ps1 @@ -0,0 +1,46 @@ +<# +.SYNOPSIS + Build and start the full InboxIntel stack using deploy/.env for secrets. +.EXAMPLE + ./deploy/up.ps1 # build + start detached + ./deploy/up.ps1 -Proxy # also start the top-level nginx reverse proxy + ./deploy/up.ps1 -Foreground # stream logs instead of detaching +#> +param( + [switch]$Proxy, + [switch]$Foreground +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot # repo root (deploy/ is one level down) +$envFile = Join-Path $PSScriptRoot '.env' + +if (-not (Test-Path $envFile)) { + throw "Missing $envFile. Create it from .env.example with your real secrets." +} + +# Fail fast if a required key is absent or blank. +$required = 'POSTGRES_PASSWORD','GOOGLE_CLIENT_ID','GOOGLE_CLIENT_SECRET' +$envMap = @{} +Get-Content $envFile | ForEach-Object { + if ($_ -match '^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$') { $envMap[$Matches[1]] = $Matches[2].Trim() } +} +$missing = $required | Where-Object { [string]::IsNullOrWhiteSpace($envMap[$_]) } +if ($missing) { throw "deploy/.env is missing values for: $($missing -join ', ')" } + +$composeArgs = @('compose', '--env-file', $envFile) +if ($Proxy) { $composeArgs += @('--profile', 'proxy') } +$composeArgs += @('up', '--build') +if (-not $Foreground) { $composeArgs += '-d' } + +Push-Location $root +try { + Write-Host "Starting InboxIntel via docker compose (env: deploy/.env)..." -ForegroundColor Cyan + & docker @composeArgs + if (-not $Foreground) { + & docker compose --env-file $envFile ps + Write-Host "`nFrontend: http://localhost:8081 API/Swagger: http://localhost:8080/swagger" -ForegroundColor Green + Write-Host "Logs: ./deploy/logs.ps1 Stop: ./deploy/down.ps1" -ForegroundColor DarkGray + } +} +finally { Pop-Location } diff --git a/deploy/up.sh b/deploy/up.sh new file mode 100644 index 0000000..4c0453f --- /dev/null +++ b/deploy/up.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Build and start the InboxIntel stack using deploy/.env for secrets. +# Usage: ./deploy/up.sh [--proxy] [--foreground] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +ENV_FILE="$SCRIPT_DIR/.env" + +[ -f "$ENV_FILE" ] || { echo "Missing $ENV_FILE. Create it from .env.example." >&2; exit 1; } + +# Fail fast if a required key is blank. +for key in POSTGRES_PASSWORD GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET; do + val="$(grep -E "^${key}=" "$ENV_FILE" | head -1 | cut -d= -f2- | tr -d '[:space:]' || true)" + [ -n "$val" ] || { echo "deploy/.env is missing a value for: $key" >&2; exit 1; } +done + +PROFILE_ARGS=() +DETACH="-d" +for arg in "$@"; do + case "$arg" in + --proxy) PROFILE_ARGS=(--profile proxy) ;; + --foreground) DETACH="" ;; + *) echo "Unknown arg: $arg" >&2; exit 1 ;; + esac +done + +cd "$ROOT" +echo "Starting InboxIntel via docker compose (env: deploy/.env)..." +docker compose --env-file "$ENV_FILE" "${PROFILE_ARGS[@]}" up --build $DETACH + +if [ -n "$DETACH" ]; then + docker compose --env-file "$ENV_FILE" ps + echo + echo "Frontend: http://localhost:8081 API/Swagger: http://localhost:8080/swagger" +fi diff --git a/docs/GOOGLE_OAUTH_SETUP.md b/docs/GOOGLE_OAUTH_SETUP.md new file mode 100644 index 0000000..f8c19ac --- /dev/null +++ b/docs/GOOGLE_OAUTH_SETUP.md @@ -0,0 +1,62 @@ +# Google OAuth2 Setup + +InboxIntel logs users in with Google only. The `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` you create here identify the **application** to Google — every user (you or anyone else) signs in through this one credential pair. They are required; without them nobody can log in. + +## 1. Create the OAuth client + +1. Go to the [Google Cloud Console](https://console.cloud.google.com/) and create (or pick) a project. +2. **APIs & Services → Library →** enable the **Gmail API**. +3. **APIs & Services → OAuth consent screen:** + - User type: **External**. + - Fill app name, support email, developer email. + - **Scopes:** add `openid`, `email`, `profile`, `.../auth/gmail.readonly`, `.../auth/gmail.modify`. +4. **APIs & Services → Credentials → Create credentials → OAuth client ID:** + - Application type: **Web application**. + - **Authorized redirect URIs** — add the one(s) matching how you run it: + + | How you run it | Redirect URI | + |---|---| + | Docker (frontend on :8081) | `http://localhost:8081/signin-google` | + | Docker + top-level nginx (`--profile proxy`, :80) | `http://localhost/signin-google` | + | Local dev (API via `dotnet run`, :5080) | `http://localhost:5080/signin-google` | + | Production | `https://your-domain/signin-google` | + + - Copy the generated **Client ID** and **Client secret**. + +## 2. Put the credentials where the app reads them + +Docker — in `.env` at the repo root: + +``` +GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=xxxxx +``` + +Local dev — use user-secrets so they never touch source control: + +```bash +cd src/InboxIntel.Api +dotnet user-secrets init +dotnet user-secrets set "GoogleOAuth:ClientId" "xxxxx.apps.googleusercontent.com" +dotnet user-secrets set "GoogleOAuth:ClientSecret" "xxxxx" +``` + +## 3. Who is allowed to log in + +This is controlled by the **consent screen publishing status**, not the app code (the code already creates a new user row per Google account — it does not restrict to one person): + +- **Testing** (default): only Google accounts you add under *Test users* can sign in (max 100). +- **In production** (click **Publish app**): any Google account can sign in. + +### Caveat for the Gmail scopes + +`gmail.readonly` and `gmail.modify` are **restricted scopes**. A published-but-unverified app still works, but: + +- users see a "Google hasn't verified this app" warning screen, and +- you're capped at **100 users** until Google verifies the app. + +Removing the warning / going beyond 100 users requires Google's OAuth verification (a CASA security assessment for restricted scopes). For personal or small-team use, published-unverified (≤100 users) is usually fine. + +## 4. Redirect URI must match exactly + +The URI registered in the Console must character-for-character match what the app sends. The app builds it from the request's host + scheme; behind nginx, `UseForwardedHeaders` + the `X-Forwarded-Proto`/`Host` headers (already configured) make that the **external** URL, and nginx routes `/signin-google` to the API. If you get `redirect_uri_mismatch`, compare the URI in the browser's address bar during the error against what's registered. diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 65af464..0f6a95c 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -15,6 +15,18 @@ server { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Cookie $http_cookie; + } + + # Google OAuth2 callback + sign-out land here (not under /api) and must + # reach the backend so the cookie session is established same-origin. + location ~ ^/(signin-google|signout-google) { + proxy_pass http://api:8080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; proxy_set_header Cookie $http_cookie; } } diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 9966300..c1c7bb1 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -11,6 +11,17 @@ server { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Cookie $http_cookie; + } + + # Google OAuth2 callback + sign-out -> backend (same-origin session). + location ~ ^/(signin-google|signout-google) { + proxy_pass http://api_upstream; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; proxy_set_header Cookie $http_cookie; } diff --git a/src/InboxIntel.Api/Program.cs b/src/InboxIntel.Api/Program.cs index 413804c..d7a25b5 100644 --- a/src/InboxIntel.Api/Program.cs +++ b/src/InboxIntel.Api/Program.cs @@ -9,6 +9,7 @@ using InboxIntel.Infrastructure.Persistence; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Google; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Serilog; @@ -89,6 +90,16 @@ if (app.Environment.IsDevelopment()) app.UseSwaggerUI(); } +// Honor X-Forwarded-* from the nginx reverse proxy so OAuth redirect URIs and +// cookie Secure flags reflect the external scheme/host, not the container's. +var forwardedOptions = new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost +}; +forwardedOptions.KnownNetworks.Clear(); +forwardedOptions.KnownProxies.Clear(); +app.UseForwardedHeaders(forwardedOptions); + app.UseSerilogRequestLogging(); app.UseCors("frontend"); app.UseAuthentication();