Complete guide to test per-widget authentication with real OAuth flow.
- Python 3.11+
- Node.js 18+
- ngrok account (free tier works)
- Auth0 or Okta account (free tier works)
- Go to https://auth0.com/signup
- Create a free account
- Note your domain:
YOUR_TENANT.us.auth0.com
- Dashboard → Applications → APIs
- Click "Create API"
- Fill in:
- Name: FastApps Test API
- Identifier:
https://fastapps-test.example.com(can be any URL) - Signing Algorithm: RS256
- Click "Create"
- Save the Identifier - you'll need it as
auth_audience
- In your API → Permissions tab
- Add these permissions:
user- Basic user accessread:data- Read data permissionwrite:data- Write data permissionadmin- Admin access
- In your API → Settings tab
- Scroll to "RBAC Settings"
- Toggle ON: Enable RBAC
- Toggle ON: Add Permissions in the Access Token
- Click "Save"
- Dashboard → Settings (gear icon in left sidebar)
- Scroll to "API Authorization Settings"
- Find "Dynamic Client Registration"
- Toggle ON: OIDC Dynamic Application Registration
- Click "Save"
- Dashboard → User Management → Users
- Click "Create User"
- Fill in email and password
- After creation, click on the user
- Go to "Permissions" tab
- Click "Assign Permissions"
- Select your API and assign all permissions
- Click "Add Permissions"
You'll need these values:
ISSUER_URL: https://YOUR_TENANT.us.auth0.com
RESOURCE_SERVER_URL: https://YOUR_NGROK_URL.ngrok-free.app/mcp
AUDIENCE: https://fastapps-test.example.com (from Step 2)
cd /Users/yunhyeok/Desktop/fastapps
mkdir auth-test
cd auth-test
# Create venv
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install FastApps
uv pip install -e ../FastApps
# Install dependencies
uv pip install httpx PyJWT cryptography
# Initialize project
uv run fastapps init auth-test-project
cd auth-test-project
# Install JS dependencies
npm installCreate three test widgets to test all decorator types:
uv run fastapps create protected-widgetEdit server/tools/protected_widget_tool.py:
from fastapps import BaseWidget, ConfigDict, auth_required, UserContext
from pydantic import BaseModel
from typing import Dict, Any
class ProtectedWidgetInput(BaseModel):
model_config = ConfigDict(populate_by_name=True)
message: str = "Hello"
@auth_required(scopes=["user", "read:data"])
class ProtectedWidgetTool(BaseWidget):
identifier = "protected-widget"
title = "Protected Widget"
description = "This widget requires authentication with user and read:data scopes"
input_schema = ProtectedWidgetInput
invoking = "Loading protected widget..."
invoked = "Protected widget loaded!"
widget_csp = {
"connect_domains": [],
"resource_domains": []
}
async def execute(self, input_data: ProtectedWidgetInput, context=None, user: UserContext = None) -> Dict[str, Any]:
if not user or not user.is_authenticated:
return {"error": "This should never happen - auth is required"}
return {
"message": f"Hello, {user.claims.get('name', 'User')}!",
"user_id": user.subject,
"email": user.claims.get('email', 'N/A'),
"scopes": user.scopes,
"has_read": user.has_scope("read:data"),
"has_write": user.has_scope("write:data"),
"has_admin": user.has_scope("admin"),
"input_message": input_data.message,
}uv run fastapps create public-widgetEdit server/tools/public_widget_tool.py:
from fastapps import BaseWidget, ConfigDict, no_auth
from pydantic import BaseModel
from typing import Dict, Any
class PublicWidgetInput(BaseModel):
model_config = ConfigDict(populate_by_name=True)
@no_auth
class PublicWidgetTool(BaseWidget):
identifier = "public-widget"
title = "Public Widget"
description = "This widget is public - no authentication required"
input_schema = PublicWidgetInput
invoking = "Loading public widget..."
invoked = "Public widget loaded!"
widget_csp = {
"connect_domains": [],
"resource_domains": []
}
async def execute(self, input_data: PublicWidgetInput, context=None, user=None) -> Dict[str, Any]:
return {
"message": "This is public content",
"authenticated": user.is_authenticated if user else False,
"note": "Anyone can access this widget"
}uv run fastapps create flexible-widgetEdit server/tools/flexible_widget_tool.py:
from fastapps import BaseWidget, ConfigDict, optional_auth, UserContext
from pydantic import BaseModel
from typing import Dict, Any
class FlexibleWidgetInput(BaseModel):
model_config = ConfigDict(populate_by_name=True)
@optional_auth(scopes=["user"])
class FlexibleWidgetTool(BaseWidget):
identifier = "flexible-widget"
title = "Flexible Widget"
description = "This widget works for both authenticated and anonymous users"
input_schema = FlexibleWidgetInput
invoking = "Loading flexible widget..."
invoked = "Flexible widget loaded!"
widget_csp = {
"connect_domains": [],
"resource_domains": []
}
async def execute(self, input_data: FlexibleWidgetInput, context=None, user: UserContext = None) -> Dict[str, Any]:
if user and user.is_authenticated:
# Premium features for authenticated users
return {
"tier": "premium",
"message": f"Welcome back, {user.claims.get('name', 'User')}!",
"user_id": user.subject,
"features": ["basic", "advanced", "export", "share"],
"personalized": True,
}
# Basic features for anonymous users
return {
"tier": "free",
"message": "Welcome! Sign in to unlock premium features.",
"features": ["basic"],
"personalized": False,
}Edit server/main.py to enable OAuth:
from pathlib import Path
import sys
import importlib
import inspect
sys.path.insert(0, str(Path(__file__).parent.parent))
from fastapps import WidgetBuilder, WidgetMCPServer, BaseWidget
import uvicorn
PROJECT_ROOT = Path(__file__).parent.parent
TOOLS_DIR = Path(__file__).parent / "tools"
def auto_load_tools(build_results):
"""Automatically discover and load all widget tools."""
tools = []
for tool_file in TOOLS_DIR.glob("*_tool.py"):
module_name = tool_file.stem
try:
module = importlib.import_module(f"server.tools.{module_name}")
for name, obj in inspect.getmembers(module, inspect.isclass):
if issubclass(obj, BaseWidget) and obj is not BaseWidget:
tool_identifier = obj.identifier
if tool_identifier in build_results:
tool_instance = obj(build_results[tool_identifier])
tools.append(tool_instance)
print(f"✓ Loaded tool: {name} (identifier: {tool_identifier})")
else:
print(f"⚠ Warning: No build result found for tool '{tool_identifier}'")
except Exception as e:
print(f"✗ Error loading {tool_file.name}: {e}")
return tools
# Build all widgets
builder = WidgetBuilder(PROJECT_ROOT)
build_results = builder.build_all()
# Auto-load and register tools
tools = auto_load_tools(build_results)
# IMPORTANT: Replace these with your Auth0 values
AUTH0_DOMAIN = "YOUR_TENANT.us.auth0.com" # Replace with your Auth0 domain
AUTH0_AUDIENCE = "https://fastapps-test.example.com" # Replace with your API identifier
NGROK_URL = "https://YOUR_URL.ngrok-free.app" # You'll get this after starting ngrok
# Create MCP server WITH authentication
server = WidgetMCPServer(
name="fastapps-auth-test",
widgets=tools,
# OAuth configuration
auth_issuer_url=f"https://{AUTH0_DOMAIN}",
auth_resource_server_url=f"{NGROK_URL}/mcp",
auth_audience=AUTH0_AUDIENCE,
auth_required_scopes=["user"], # Default scope for widgets without decorators
)
app = server.get_app()
if __name__ == "__main__":
print(f"\n🚀 Starting FastApps Test Server with OAuth")
print(f" Auth Provider: {AUTH0_DOMAIN}")
print(f" Widgets: {len(tools)}")
print(f" Port: 8001")
print(f"\n⚠️ Remember to start ngrok in another terminal:")
print(f" ngrok http 8001")
print(f"\n📝 After getting ngrok URL, update NGROK_URL in this file")
print(f"\n✨ Test widgets:")
for tool in tools:
auth_status = "🔒 AUTH REQUIRED" if getattr(tool, '_auth_required', None) is True else \
"🌐 PUBLIC" if getattr(tool, '_auth_required', None) is False else \
"🔓 OPTIONAL AUTH"
print(f" - {tool.title}: {auth_status}")
print()
uvicorn.run(app, host="0.0.0.0", port=8001)npm run buildYou should see:
Found widget: protected-widget
Found widget: public-widget
Found widget: flexible-widget
Ready to build 3 widget(s)
python server/main.pyYou should see:
🚀 Starting FastApps Test Server with OAuth
Auth Provider: YOUR_TENANT.us.auth0.com
Widgets: 3
Port: 8001
⚠️ Remember to start ngrok in another terminal:
ngrok http 8001
ngrok http 8001You'll get output like:
Session Status online
Forwarding https://abc123.ngrok-free.app -> http://localhost:8001
IMPORTANT: Copy the https://...ngrok-free.app URL!
- Stop the server (Ctrl+C in Terminal 1)
- Edit
server/main.py - Update
NGROK_URL = "https://abc123.ngrok-free.app"with your actual URL - Restart server:
python server/main.py
- Open ChatGPT
- Go to Settings (gear icon)
- Click "Connectors"
- Click "Add Connector"
- Enter your ngrok URL with
/mcppath:https://abc123.ngrok-free.app/mcp - Click "Add"
ChatGPT will prompt you to authenticate:
- Click "Authenticate"
- You'll be redirected to Auth0
- Log in with the test user you created
- Grant permissions
- You'll be redirected back to ChatGPT
Try these commands in ChatGPT:
Test 1: Protected Widget (should require auth)
Use the protected-widget tool with message "Testing auth"
Expected response:
- Shows your user info
- Shows your scopes
- Works because you're authenticated
Test 2: Public Widget (works without auth)
Use the public-widget tool
Expected response:
- Works immediately
- Shows authenticated status
- No auth prompt
Test 3: Flexible Widget (works both ways)
Use the flexible-widget tool
Expected response (authenticated):
- Shows "premium" tier
- Shows personalized features
- Shows your user info
Try this in an incognito/private window (unauthenticated):
- Should show "free" tier
- Shows basic features only
You can verify the securitySchemes are set correctly:
curl http://localhost:8001/mcp/toolsLook for:
{
"tools": [
{
"name": "protected-widget",
"_meta": {
"securitySchemes": [
{"type": "oauth2", "scopes": ["user", "read:data"]}
]
}
},
{
"name": "public-widget",
"_meta": {
"securitySchemes": [
{"type": "noauth"}
]
}
},
{
"name": "flexible-widget",
"_meta": {
"securitySchemes": [
{"type": "noauth"},
{"type": "oauth2", "scopes": ["user"]}
]
}
}
]
}curl http://localhost:8001/.well-known/oauth-protected-resourceShould return Auth0 configuration.
Watch terminal for:
✓ Loaded tool: ProtectedWidgetTool (identifier: protected-widget)✓ Loaded tool: PublicWidgetTool (identifier: public-widget)✓ Loaded tool: FlexibleWidgetTool (identifier: flexible-widget)
Cause: Token not present or invalid
Solution:
- Make sure you authenticated in ChatGPT
- Check Auth0 user has permissions assigned
- Verify
auth_audiencematches API identifier - Check
auth_issuer_urlis correct
Cause: User doesn't have the required scopes
Solution:
- Go to Auth0 Dashboard → Users
- Click on your test user
- Go to Permissions tab
- Assign missing permissions
Cause: ngrok generates new URL on restart (free tier)
Solution:
- Get new ngrok URL
- Update
NGROK_URLinserver/main.py - Restart server
- Update connector URL in ChatGPT
Cause: Build issue or widget not loaded
Solution:
# Rebuild
npm run build
# Check build output
ls assets/
# Should see:
# protected-widget-XXXX.html
# public-widget-XXXX.html
# flexible-widget-XXXX.html| Widget | Decorator | Anonymous Access | Authenticated Access |
|---|---|---|---|
| Protected | @auth_required |
❌ Error | ✅ Works, shows user info |
| Public | @no_auth |
✅ Works | ✅ Works, notes auth status |
| Flexible | @optional_auth |
✅ Basic features | ✅ Premium features |
After successful testing:
- Add more scopes: Test with
adminscope - Test scope enforcement: Remove scopes from user, verify errors
- Test inheritance: Create widget without decorator, verify inherits server auth
- Test error handling: Try invalid tokens, expired tokens
- Production deployment: Use real domain instead of ngrok
When done testing:
# Stop server (Terminal 1): Ctrl+C
# Stop ngrok (Terminal 2): Ctrl+C
# Deactivate venv
deactivate
# Remove test project (optional)
cd /Users/yunhyeok/Desktop/fastapps
rm -rf auth-testNeed help? Check:
- Auth0 Logs: Dashboard → Monitoring → Logs
- Server logs: Terminal 1
- ChatGPT connector status: Settings → Connectors
- FastApps docs:
/docs/08-AUTH.mdand/docs/09-PER-WIDGET-AUTH.md