Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,24 +424,31 @@ Or capture the full session object server-side and base64 encode it:
}
```

**2. Set the environment variable:**
**2. Set the environment variables:**
```bash
export TEST_SESSION_COOKIE="eyJwYXNzc...base64..."
export SESSION_INJECTION_SECRET="your-secret-token"
```

**3. Authenticate via the injection endpoint:**
```bash
curl http://localhost:9142/auth/inject-session
# Uses APP_URL from .agent-env or your configured port
curl -X POST "${APP_URL}/auth/inject-session" -H "X-Injection-Secret: ${SESSION_INJECTION_SECRET}"
# Redirects to /timesheet with valid session
```

In Playwright tests:
```typescript
test('authenticated flow', async ({ page }) => {
test('authenticated flow', async ({ page, request }) => {
// Inject session before testing protected routes
await page.goto(`${process.env.APP_URL}/auth/inject-session`)
await request.post(`${process.env.APP_URL}/auth/inject-session`, {
headers: {
'X-Injection-Secret': process.env.SESSION_INJECTION_SECRET
}
})

// Now authenticated - test protected functionality
await page.goto(`${process.env.APP_URL}/timesheet`)
await expect(page.locator('h1')).toContainText('Timesheet')
})
```
Expand Down
61 changes: 48 additions & 13 deletions scripts/agent-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,28 @@ cd "$ROOT_DIR"
info() { echo -e "[agent-setup] $*"; }
error() { echo -e "[agent-setup][ERROR] $*" >&2; }

# List of environment variables to load from parent .env
ALLOWED_ENV_VARS=(
"MICROSOFT_CLIENT_ID"
"MICROSOFT_CLIENT_SECRET"
"TEST_SESSION_COOKIE"
"SESSION_INJECTION_SECRET"
)

# --- Derive unique identifiers from worktree path ---
WORKTREE_NAME=$(basename "$ROOT_DIR")
# Normalize worktree name for Docker Compose compatibility:
# - convert to lowercase
# - replace any non-alphanumeric characters with hyphens
NORMALIZED_WORKTREE_NAME="$(printf '%s' "$WORKTREE_NAME" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g')"
# Generate a stable port offset from worktree name (hash to 1-99 range, add to base 9100)
PORT_OFFSET=$(echo -n "$WORKTREE_NAME" | cksum | cut -d' ' -f1)
PORT_OFFSET=$((PORT_OFFSET % 99 + 1))
APP_PORT=$((9100 + PORT_OFFSET))
MONGO_PORT=$((27100 + PORT_OFFSET))
REDIS_PORT=$((6400 + PORT_OFFSET))

export COMPOSE_PROJECT_NAME="did-${WORKTREE_NAME}"
export COMPOSE_PROJECT_NAME="did-${NORMALIZED_WORKTREE_NAME}"

info "Worktree: $WORKTREE_NAME"
info "Ports: app=$APP_PORT, mongo=$MONGO_PORT, redis=$REDIS_PORT"
Expand All @@ -30,17 +42,39 @@ info "Project: $COMPOSE_PROJECT_NAME"
PARENT_ENV="${ROOT_DIR}/../.env"
if [[ -f "$PARENT_ENV" ]]; then
info "Loading credentials from parent .env"
set -a
# shellcheck source=/dev/null
source "$PARENT_ENV"
set +a
while IFS= read -r line; do
# Skip empty lines and comments
[[ -z "${line}" || "${line}" =~ ^[[:space:]]*# ]] && continue
# Skip lines without '=' (malformed)
[[ ! "${line}" =~ = ]] && continue
# Split only on first '=' to handle values containing '='
key="${line%%=*}"
value="${line#*=}"

# Check if key is in allowlist
for allowed_key in "${ALLOWED_ENV_VARS[@]}"; do
if [[ "${key}" == "${allowed_key}" ]]; then
# Only set from parent .env if not already present in environment
if [[ -z "${!key-}" && -n "${value}" ]]; then
# Strip surrounding quotes (both single and double)
value="${value#\"}"
value="${value%\"}"
value="${value#\'}"
value="${value%\'}"
export "${key}=${value}"
fi
break
fi
done
done < "$PARENT_ENV"
fi

# --- Required env vars (from parent .env, CI secrets, or host env) ---
: "${MICROSOFT_CLIENT_ID:?Missing MICROSOFT_CLIENT_ID - set in environment or parent .env}"
: "${MICROSOFT_CLIENT_SECRET:?Missing MICROSOFT_CLIENT_SECRET - set in environment or parent .env}"
# Optional: for session injection
TEST_SESSION_COOKIE="${TEST_SESSION_COOKIE:-}"
SESSION_INJECTION_SECRET="${SESSION_INJECTION_SECRET:-}"

# --- Generate docker-compose.local.yml with credentials + port overrides ---
info "Generating docker-compose.local.yml"
Expand All @@ -50,11 +84,12 @@ services:
ports:
- "${APP_PORT}:9001"
environment:
- MICROSOFT_CLIENT_ID=${MICROSOFT_CLIENT_ID}
- MICROSOFT_CLIENT_SECRET=${MICROSOFT_CLIENT_SECRET}
- MICROSOFT_REDIRECT_URI=http://localhost:${APP_PORT}/auth/azuread-openidconnect/callback
- ENABLE_SESSION_INJECTION=${TEST_SESSION_COOKIE:+true}
- TEST_SESSION_COOKIE=${TEST_SESSION_COOKIE}
- MICROSOFT_CLIENT_ID="${MICROSOFT_CLIENT_ID}"
- MICROSOFT_CLIENT_SECRET="${MICROSOFT_CLIENT_SECRET}"
- MICROSOFT_REDIRECT_URI="http://localhost:${APP_PORT}/auth/azuread-openidconnect/callback"
- ENABLE_SESSION_INJECTION="${TEST_SESSION_COOKIE:+true}"
- TEST_SESSION_COOKIE="${TEST_SESSION_COOKIE}"
- SESSION_INJECTION_SECRET="${SESSION_INJECTION_SECRET}"
mongodb:
ports:
- "${MONGO_PORT}:27017"
Expand Down Expand Up @@ -126,11 +161,11 @@ echo "MONGO_URL=mongodb://localhost:${MONGO_PORT}"
echo "REDIS_URL=redis://localhost:${REDIS_PORT}"
echo "COMPOSE_PROJECT=${COMPOSE_PROJECT_NAME}"
echo ""
if [[ -n "$TEST_SESSION_COOKIE" ]]; then
if [[ -n "$TEST_SESSION_COOKIE" && -n "$SESSION_INJECTION_SECRET" ]]; then
info "Session injection enabled. To authenticate:"
echo " curl http://localhost:${APP_PORT}/auth/inject-session"
echo " curl -X POST http://localhost:${APP_PORT}/auth/inject-session -H \"X-Injection-Secret: \$SESSION_INJECTION_SECRET\""
else
info "Session injection disabled (no TEST_SESSION_COOKIE provided)"
info "Session injection disabled (requires TEST_SESSION_COOKIE and SESSION_INJECTION_SECRET)"
fi
echo ""
info "To tear down: ./scripts/agent-teardown.sh"
6 changes: 5 additions & 1 deletion scripts/agent-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ info() { echo -e "[agent-teardown] $*"; }
warn() { echo -e "[agent-teardown][WARN] $*"; }

WORKTREE_NAME=$(basename "$ROOT_DIR")
export COMPOSE_PROJECT_NAME="did-${WORKTREE_NAME}"
# Normalize worktree name to match agent-setup:
# - convert to lowercase
# - replace any non-alphanumeric characters with hyphens
NORMALIZED_WORKTREE_NAME="$(printf '%s' "$WORKTREE_NAME" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g')"
export COMPOSE_PROJECT_NAME="did-${NORMALIZED_WORKTREE_NAME}"

# Parse arguments
REMOVE_WORKTREE=0
Expand Down
6 changes: 6 additions & 0 deletions scripts/docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ check_docker() {
error "Docker is not running. Please start Docker first."
exit 1
fi

if ! docker compose version &> /dev/null; then
error "Docker Compose plugin is not installed or enabled."
echo " Visit: https://docs.docker.com/compose/install/"
exit 1
fi
}

# ─────────────────────────────────────────────────────────────────────────────
Expand Down
83 changes: 70 additions & 13 deletions server/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { NextFunction, Request, Response, Router } from 'express'
import passport from 'passport'
import _ from 'underscore'
import url from 'url'
import crypto from 'crypto'
import {
GENERIC_SIGNIN_FAILED,
SigninError
Expand Down Expand Up @@ -160,41 +161,97 @@ auth.get('/signout', signOutHandler)
* Session injection for agent/e2e testing (development only)
*
* Allows agents and Playwright tests to authenticate without going through OAuth.
* Requires ENABLE_SESSION_INJECTION=true and TEST_SESSION_COOKIE to be set.
* Requires NODE_ENV=development, ENABLE_SESSION_INJECTION=true, and TEST_SESSION_COOKIE to be set.
* The TEST_SESSION_COOKIE should be a base64-encoded JSON object containing
* the user session data (copy from a real logged-in session).
*
* @example
* curl http://localhost:9142/auth/inject-session
* curl -X POST "$APP_URL/auth/inject-session" -H "X-Injection-Secret: $SESSION_INJECTION_SECRET"
*/
if (
environment('NODE_ENV') === 'development' &&
environment('ENABLE_SESSION_INJECTION', false, { isSwitch: true }) &&
environment('TEST_SESSION_COOKIE')
environment('TEST_SESSION_COOKIE') &&
environment('SESSION_INJECTION_SECRET')
) {
auth.get('/inject-session', (request: Request, response: Response) => {
auth.post('/inject-session', (request: Request, response: Response) => {
try {
// Verify secret token with timing-safe comparison
// Using request.get() for case-insensitive header lookup
const providedSecret = request.get('x-injection-secret')
const expectedSecret = environment('SESSION_INJECTION_SECRET') as string

// Validate secret exists and lengths match before timing-safe comparison
if (
!providedSecret ||
typeof providedSecret !== 'string' ||
providedSecret.length !== expectedSecret.length
) {
debug('Session injection failed: invalid or missing secret')
return response.status(403).json({ error: 'Invalid secret' })
}

// Timing-safe comparison to prevent timing attacks
if (
!crypto.timingSafeEqual(
Buffer.from(providedSecret),
Buffer.from(expectedSecret)
)
) {
debug('Session injection failed: secret mismatch')
return response.status(403).json({ error: 'Invalid secret' })
}

const sessionData = JSON.parse(
Buffer.from(
environment('TEST_SESSION_COOKIE') as string,
'base64'
).toString('utf8')
)
debug('Injecting session for user: %s', sessionData?.passport?.user?.mail)
Object.assign(request.session, sessionData)
request.session.save((error) => {
if (error) {
debug('Session injection failed: %s', error.message)
return response.status(500).json({ error: 'Session save failed' })

const passportData = sessionData?.passport
if (!passportData) {
debug('Session injection error: missing passport data in TEST_SESSION_COOKIE')
return response.status(400).json({ error: 'Invalid TEST_SESSION_COOKIE' })
}

debug('Injecting session for user: %s', passportData?.user?.mail)

// Regenerate session before injection to prevent fixation
request.session.regenerate((regenerateError) => {
if (regenerateError) {
debug('Session regeneration failed: %s', regenerateError.message)
return response.status(500).json({ error: 'Session regeneration failed' })
}
const redirectUrl = sessionData?.passport?.user?.startPage || '/timesheet'
return response.redirect(redirectUrl)

// Only copy allowlisted field (passport) onto new session
request.session['passport'] = passportData

request.session.save((error) => {
if (error) {
debug('Session injection failed: %s', error.message)
return response.status(500).json({ error: 'Session save failed' })
}

// Restrict redirectUrl to internal relative paths only
let redirectUrl = passportData?.user?.startPage || '/timesheet'
if (
typeof redirectUrl !== 'string' ||
!redirectUrl.startsWith('/') ||
redirectUrl.startsWith('//')
) {
redirectUrl = '/timesheet'
}

return response.redirect(redirectUrl)
})
})
} catch (error) {
debug('Session injection parse error: %s', (error as Error).message)
return response.status(400).json({ error: 'Invalid TEST_SESSION_COOKIE' })
}
})
debug('Session injection route enabled at /auth/inject-session')
debug('Session injection route enabled at POST /auth/inject-session')
}

export default auth