# Tracer Bullet Implementation: Jupyter OAuth SSO with IAP Access

## Purpose

This document provides a **minimal, working prototype** to prove the OAuth → IAP authentication flow works end-to-end in a Jupyter notebook. This is NOT production code - it's a tracer bullet to validate the approach before building the full library.

## Tracer Bullet Goals

**Prove these 3 things work:**

1. ✅ OAuth flow from Jupyter → browser → token storage
2. ✅ Stored tokens can make authenticated requests to IAP-protected resource
3. ✅ Token refresh works automatically

**Explicitly OUT OF SCOPE:**

- Service accounts
- Headless/SSH scenarios
- Concurrent access handling
- Production error handling
- Library packaging
- Multiple users/projects
- Comprehensive documentation


## Project Overview

This project demonstrates OAuth 2.0 authentication from a Jupyter notebook to access an IAP-protected Cloud Run service. It consists of two main components:

1. **Cloud Run Service** (`app.py`): A minimal FastAPI application deployed to GCP that returns IAP authentication headers. This service is protected by Identity-Aware Proxy (IAP) and requires valid OAuth credentials to access.

2. **Jupyter Notebook** (`oauth-iap-prototype.ipynb`): An interactive notebook that implements the OAuth "Desktop App" flow, stores credentials locally, and makes authenticated requests to the IAP-protected service.

The project uses **UV** for Python dependency management and supports both local development and cloud deployment.

### Repository Structure

```
jupyter-iap-test/
├── pyproject.toml                 # UV project config with production + dev dependencies
├── uv.lock                        # Locked dependency versions (generated by uv)
├── app.py                         # FastAPI service that returns IAP auth headers
├── Dockerfile                     # Container definition for Cloud Run deployment
├── oauth-iap-prototype.ipynb      # Jupyter notebook with OAuth flow implementation
├── design.md                      # This document - implementation guide
└── .venv/                         # Virtual environment (auto-created by uv sync)
```

**What gets deployed to GCP:**
- `app.py` → Cloud Run service (via Dockerfile)

**What runs locally:**
- `oauth-iap-prototype.ipynb` → Jupyter notebook for testing OAuth flow

**Configuration files:**
- You'll download a JSON file from GCP Console containing OAuth client credentials (not checked into repo)
- Token file (`~/.jupyter_iap_token_test.json`) gets created in your home directory after first OAuth flow

## Implementation Steps

Below are the steps for implementing this test setup.

### Step 1: GCP Setup (One-Time)

Set up a GCP project and endpoint to use for testing

#### 1.1 Create a new GCP project for the test

**Option 1: Create brand new project**

**CLI Instructions:**
```bash
# Create new project (replace RANDOM-ID with something unique like your initials + date)
gcloud projects create jupyter-iap-test-[RANDOM-ID] --name="Jupyter IAP Test"

# Set as active project
gcloud config set project jupyter-iap-test-[RANDOM-ID]

# Enable required APIs
gcloud services enable iap.googleapis.com
gcloud services enable run.googleapis.com
```

**GUI Instructions:**
> 1. Go to [GCP Console](https://console.cloud.google.com)
> 2. Click the project dropdown in the top navigation bar
> 3. Click "New Project" in the upper right of the dialog
> 4. Enter project name: "Jupyter IAP Test"
> 5. Note the auto-generated Project ID (you'll need this)
> 6. Click "Create"
> 7. Wait for project creation notification
> 8. Switch to the new project using the project dropdown
> 9. Go to "APIs & Services" → "Library"
> 10. Search for "Identity-Aware Proxy API" and click "Enable"
> 11. Search for "Cloud Run Admin API" and click "Enable"

**Option 2: Use existing project**

**CLI Instructions:**
```bash
# List your projects
gcloud projects list

# Set project (replace with your project ID)
gcloud config set project YOUR-EXISTING-PROJECT-ID

# Enable required APIs
gcloud services enable iap.googleapis.com
gcloud services enable run.googleapis.com
```

**GUI Instructions:**
> 1. Go to [GCP Console](https://console.cloud.google.com)
> 2. Use the project dropdown to select your existing project
> 3. Go to "APIs & Services" → "Library"
> 4. Search for "Identity-Aware Proxy API" and click "Enable"
> 5. Search for "Cloud Run Admin API" and click "Enable"

**Verify configuration:**
```bash
gcloud config list
```

#### 1.2 Configure OAuth Consent Screen

Before creating OAuth credentials, you must configure the consent screen.

1. Go to [APIs & Services → OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent)
2. Choose "Internal" (if using Google Workspace) or "External"
3. Click "Create"
4. Fill in required fields:
   - App name: "Jupyter IAP Test"
   - User support email: your email
   - Developer contact: your email
5. Click "Save and Continue"
6. Scopes: Click "Save and Continue" (we'll specify scopes in code)
7. Test users: If using "External", add your email address
8. Click "Save and Continue"
9. Click "Back to Dashboard"

#### 1.3 Create OAuth Client (Desktop App)

1. Go to [GCP Console → APIs & Services → Credentials](https://console.cloud.google.com/apis/credentials)
2. Click "Create Credentials" → "OAuth client ID"
3. Choose "Desktop app" as application type
4. Name it "jupyter-iap-test"
5. Click "Create"
6. Download JSON file to the project location → extract `client_id` and `client_secret`

#### 1.4 Deploy Minimal IAP-Protected Service

Create a FastAPI app that returns IAP header information.

**Create `app.py`:**
```python
from fastapi import FastAPI, Request

app = FastAPI()

@app.get("/")
def hello(request: Request):
    # IAP adds headers with user info
    user_email = request.headers.get('x-goog-authenticated-user-email', 'unknown')
    user_id = request.headers.get('x-goog-authenticated-user-id', 'unknown')

    return {
        "message": "Hello from IAP-protected service!",
        "authenticated_user_email": user_email,
        "authenticated_user_id": user_id,
        "all_headers": dict(request.headers)
    }

@app.get("/health")
def health():
    return {"status": "healthy"}
```

**Update `pyproject.toml`** to include FastAPI:
```toml
[project]
name = "jupyter-iap-test"
version = "0.1.0"
dependencies = [
    "fastapi>=0.104.0",
    "uvicorn[standard]>=0.24.0",
]

[dependency-groups]
dev = [
    "google-auth-oauthlib>=1.2.0",
    "google-auth>=2.23.0",
    "jupyter>=1.0.0",
    "requests>=2.31.0",
]
```

**Install dependencies:**
```bash
uv sync
```

**Test locally:**
```bash
uv run uvicorn app:app --host 0.0.0.0 --port 8080
# Visit http://localhost:8080 - should see JSON response
# Press Ctrl+C to stop
```

**Create `Dockerfile`:**
```dockerfile
FROM python:3.11-slim

# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

# Set working directory
WORKDIR /app

# Copy project files
COPY pyproject.toml uv.lock ./
COPY app.py ./

# Install dependencies
RUN uv sync --frozen --no-dev

# Run the application
CMD ["uv", "run", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
```

**Deploy to Cloud Run:**
```bash
# Build and deploy
gcloud run deploy iap-test-service \
  --source . \
  --region us-central1 \
  --platform managed \
  --allow-unauthenticated

# Note the service URL from output
export SERVICE_URL=$(gcloud run services describe iap-test-service --region us-central1 --format='value(status.url)')
echo "Service URL: $SERVICE_URL"
```

#### 1.5 Configure IAP Access

1. Go to [Security → Identity-Aware Proxy](https://console.cloud.google.com/security/iap)
2. Find your Cloud Run service
3. Click checkbox → Add Principal
4. Add your test email address
5. Assign role: "IAP-secured Web App User"
6. Save

**IMPORTANT:** After enabling IAP, the service becomes inaccessible without proper authentication. The OAuth client for the service will be auto-created by IAP.

#### 1.6 Get IAP Client ID

1. In IAP settings, click on your service
2. Copy the "OAuth 2.0 Client ID" (this is different from the Desktop app client ID)
3. Format: `123456789-xyz.apps.googleusercontent.com`
4. Save this Client ID - you'll need it for the notebook configuration

**Verify IAP is working:**
```bash
# Try accessing without auth (should fail)
curl $SERVICE_URL
# Should return 302 redirect to Google login or 403
```


### Step 2: Set Up Jupyter Environment with UV

Add Jupyter and auth libraries to the dev dependency group in the same project.

**Note:** The dependencies should already be in `pyproject.toml` from Step 1.4. If not, ensure your `pyproject.toml` includes:

```toml
[dependency-groups]
dev = [
    "google-auth-oauthlib>=1.2.0",
    "google-auth>=2.23.0",
    "jupyter>=1.0.0",
    "requests>=2.31.0",
]
```

**Install dependencies:**
```bash
uv sync --group dev
```

**Verify JupyterLab runs:**
```bash
uv run jupyter lab
```

A browser window should open with JupyterLab interface. If successful, you're ready to create the prototype notebook.


### Step 3: Create Prototype Notebook

Create a new notebook: `oauth-iap-prototype.ipynb`

#### Cell 1: Configuration

```python
import os

# OAuth Desktop App credentials (from Step 1.3)
OAUTH_CLIENT_CONFIG = {
    "installed": {
        "client_id": "YOUR_DESKTOP_CLIENT_ID.apps.googleusercontent.com",
        "client_secret": "YOUR_DESKTOP_CLIENT_SECRET",
        "redirect_uris": ["http://localhost"],
        "auth_uri": "https://accounts.google.com/o/oauth2/auth",
        "token_uri": "https://oauth2.googleapis.com/token"
    }
}

# Scopes needed for IAP access
DEFAULT_SCOPES = [
    'openid',
    'email',
    'profile',
    'https://www.googleapis.com/auth/cloud-platform'
]

# IAP-protected resource details (from Step 1.6)
IAP_URL = "https://iap-test-service-xxxxx-uc.a.run.app"  # Your Cloud Run URL
IAP_CLIENT_ID = "123456789-xyz.apps.googleusercontent.com"  # IAP OAuth Client ID

# Token storage location
TOKEN_FILE = os.path.expanduser('~/.jupyter_iap_token_test.json')

print("✅ Configuration loaded")
```

#### Cell 2: OAuth Authentication Function

```python
import stat
from google_auth_oauthlib.flow import InstalledAppFlow
from google.oauth2.credentials import Credentials
import google.auth.transport.requests

def authenticate():
    """
    Run OAuth flow and return credentials.
    """
    credentials = None

    # Try to load existing credentials
    if os.path.exists(TOKEN_FILE):
        try:
            credentials = Credentials.from_authorized_user_file(TOKEN_FILE, DEFAULT_SCOPES)
            print(f"📂 Loaded credentials from {TOKEN_FILE}")
        except Exception as e:
            print(f"⚠️  Could not load credentials: {e}")
            credentials = None

    # Check if credentials are valid
    if credentials and credentials.valid:
        print("✅ Existing credentials are valid")
        return credentials

    # Try to refresh if expired
    if credentials and credentials.expired and credentials.refresh_token:
        print("🔄 Refreshing expired credentials...")
        try:
            credentials.refresh(google.auth.transport.requests.Request())
            print("✅ Credentials refreshed")
        except Exception as e:
            print(f"❌ Refresh failed: {e}")
            credentials = None

    # If still no valid credentials, run full OAuth flow
    if not credentials or not credentials.valid:
        print("🌐 Starting OAuth flow (browser will open)...")
        flow = InstalledAppFlow.from_client_config(
            OAUTH_CLIENT_CONFIG,
            scopes=DEFAULT_SCOPES
        )

        credentials = flow.run_local_server(
            port=0,  # Let OS pick free port
            open_browser=True,
            access_type='offline',  # Critical for refresh token
            prompt='consent'  # Force consent to get refresh token
        )
        print("✅ OAuth flow completed")

    # Save credentials to file with secure permissions
    with open(TOKEN_FILE, 'w') as token_file:
        token_file.write(credentials.to_json())

    # Set file permissions to 0600 (owner read/write only)
    os.chmod(TOKEN_FILE, stat.S_IRUSR | stat.S_IWUSR)
    print(f"💾 Credentials saved to {TOKEN_FILE}")

    return credentials

# Run authentication
creds = authenticate()
print(f"\n📧 Authenticated as: {creds.client_id[:30]}...")
```

#### Cell 3: IAP Request Function (CORRECTED)

```python
import requests
from google.auth.transport.requests import AuthorizedSession

def make_iap_request(url, credentials):
    """
    Make authenticated request to IAP-protected resource.

    Note: The original plan-v1.md approach using google.auth.iam.sign_id_token()
    DOES NOT WORK for user OAuth credentials. That function is for service accounts.

    For user credentials accessing IAP, we need to use AuthorizedSession which
    handles the token management automatically.
    """

    # Ensure credentials are fresh
    if credentials.expired and credentials.refresh_token:
        print("🔄 Refreshing credentials before request...")
        credentials.refresh(google.auth.transport.requests.Request())

    # Option 1: Use AuthorizedSession (simplest for OAuth credentials)
    authed_session = AuthorizedSession(credentials)

    try:
        print(f"📡 Making request to {url}...")
        response = authed_session.get(url)
        response.raise_for_status()

        print(f"✅ Response status: {response.status_code}")
        return response

    except requests.exceptions.HTTPError as e:
        print(f"❌ HTTP Error: {e}")
        print(f"   Response body: {e.response.text}")
        raise
    except Exception as e:
        print(f"❌ Request failed: {e}")
        raise

# Make request to IAP-protected service
response = make_iap_request(IAP_URL, creds)
print(f"\n📄 Response body:\n{response.text}")
```

#### Cell 4: Verify Token Refresh (Optional)

```python
import json
from datetime import datetime, timezone

# Inspect token file
with open(TOKEN_FILE, 'r') as f:
    token_data = json.load(f)

print("🔍 Token file contents:")
print(f"  - Has refresh_token: {'refresh_token' in token_data}")
print(f"  - Token expires: {token_data.get('expiry', 'N/A')}")
print(f"  - Scopes: {token_data.get('scopes', [])}")

# Test token refresh by forcing expiration
print("\n🧪 Testing token refresh...")
print("   Manually setting token to expired...")

# Modify token file to expire it
token_data['expiry'] = '2020-01-01T00:00:00Z'
with open(TOKEN_FILE, 'w') as f:
    json.dump(token_data, f)

print("   Now trying to make request with expired token...")

# Reload credentials from modified file
expired_creds = Credentials.from_authorized_user_file(TOKEN_FILE, DEFAULT_SCOPES)

# This should automatically refresh
response = make_iap_request(IAP_URL, expired_creds)
print("✅ Token refresh successful!")
```

## Validation Checklist

Run through the notebook cells in order and verify:

- [ ] Cell 1: Configuration loads without errors
- [ ] Cell 2: Browser opens, OAuth completes, token file created at `~/.jupyter_iap_token_test.json`
- [ ] Cell 2: File permissions are 0600 (run `ls -la ~/.jupyter_iap_token_test.json`)
- [ ] Cell 3: IAP request succeeds with HTTP 200
- [ ] Cell 3: Response includes authenticated user information
- [ ] Cell 4: Token refresh works without opening browser


## Expected Issues

[append as we develop to note issues]