Successful oauth flow

This commit is contained in:
Laurie Voss
2025-06-21 13:56:11 -07:00
parent 1d82d063cd
commit 83e0dd0ccb
18 changed files with 1421 additions and 338 deletions
+252
View File
@@ -1,3 +1,255 @@
# MCP OAuth Server
A Next.js-based OAuth server that provides MCP (Model Context Protocol) services with OAuth 2.0 authentication support.
## Features
- OAuth 2.0 Client Credentials flow for server-to-server authentication
- OAuth 2.0 Device Authorization Grant flow for desktop applications
- MCP server implementation with SSE (Server-Sent Events)
- Google OAuth integration for user authentication
- Prisma-based database with PostgreSQL
## Device Authorization Flow
This server supports the OAuth 2.0 Device Authorization Grant flow, which is ideal for desktop applications that need to authenticate users without a web browser redirect.
### Flow Overview
1. **Desktop app discovers the MCP server** by calling `GET /api/sse`
2. **Desktop app requests device authorization** by calling `POST /api/oauth/device`
3. **User completes authentication** on the web at `/auth/device`
4. **Desktop app polls for completion** by calling `POST /api/oauth/device/token`
5. **Desktop app connects to MCP** using the received access token
### Step-by-Step Implementation
#### 1. Discover the MCP Server
```bash
curl -X GET http://localhost:3000/api/sse
```
Response includes device authorization endpoints:
```json
{
"endpoints": {
"device": "http://localhost:3000/api/oauth/device",
"deviceToken": "http://localhost:3000/api/oauth/device/token",
"verificationUrl": "http://localhost:3000/auth/device"
},
"auth": {
"flows": {
"device_authorization": {
"deviceUrl": "http://localhost:3000/api/oauth/device",
"tokenUrl": "http://localhost:3000/api/oauth/device/token",
"verificationUrl": "http://localhost:3000/auth/device"
}
}
}
}
```
#### 2. Request Device Authorization
```bash
curl -X POST http://localhost:3000/api/oauth/device \
-H "Content-Type: application/json" \
-d '{
"clientId": "your_client_id",
"scope": ["mcp:tools:read", "mcp:tools:call"]
}'
```
Response:
```json
{
"device_code": "ABCD1234",
"user_code": "ABCD-EFGH",
"verification_uri": "http://localhost:3000/auth/device",
"verification_uri_complete": "http://localhost:3000/auth/device?code=ABCD-EFGH",
"expires_in": 600,
"interval": 5
}
```
#### 3. Display User Code
Show the user code (`ABCD-EFGH`) to the user and direct them to the verification URL.
#### 4. Poll for Authorization Completion
```bash
curl -X POST http://localhost:3000/api/oauth/device/token \
-H "Content-Type: application/json" \
-d '{
"device_code": "ABCD1234",
"grant_type": "urn:ietf:params:oauth:grant-type:device_code"
}'
```
While the user hasn't completed authorization:
```json
{
"error": "authorization_pending",
"error_description": "The user has not yet completed the authorization"
}
```
After the user completes authorization:
```json
{
"access_token": "your_access_token",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "your_refresh_token",
"scope": "mcp:tools:read mcp:tools:call"
}
```
#### 5. Connect to MCP Server
```bash
curl -X POST http://localhost:3000/api/sse \
-H "Authorization: Bearer your_access_token" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize"
}'
```
## API Endpoints
### OAuth Endpoints
- `POST /api/oauth/register` - Register a new OAuth client
- `POST /api/oauth/token` - Get access token (client credentials flow)
- `POST /api/oauth/validate` - Validate access token
- `POST /api/oauth/device` - Request device authorization
- `POST /api/oauth/device/token` - Exchange device code for token
- `POST /api/oauth/device/verify` - Verify device code (web interface)
- `POST /api/oauth/device/authorize` - Authorize device (web interface)
### MCP Endpoints
- `GET /api/sse` - Service discovery and MCP server info
- `POST /api/sse` - MCP server with SSE support
### Web Interface
- `/auth/device` - Device authorization web interface
## Setup
1. Install dependencies:
```bash
npm install
```
2. Set up environment variables:
```bash
cp .env.example .env
# Edit .env with your database and OAuth credentials
```
3. Run database migrations:
```bash
npx prisma migrate dev
```
4. Start the development server:
```bash
npm run dev
```
## Environment Variables
- `DATABASE_URL` - PostgreSQL connection string
- `GOOGLE_CLIENT_ID` - Google OAuth client ID
- `GOOGLE_CLIENT_SECRET` - Google OAuth client secret
- `NEXTAUTH_SECRET` - NextAuth secret key
- `NEXTAUTH_URL` - NextAuth URL (e.g., http://localhost:3000)
## Example Desktop Client
Here's a simple example of how a desktop application might implement this flow:
```python
import requests
import time
class MCPClient:
def __init__(self, base_url, client_id):
self.base_url = base_url
self.client_id = client_id
self.access_token = None
def discover_server(self):
response = requests.get(f"{self.base_url}/api/sse")
return response.json()
def request_device_auth(self):
response = requests.post(f"{self.base_url}/api/oauth/device", json={
"clientId": self.client_id,
"scope": ["mcp:tools:read", "mcp:tools:call"]
})
return response.json()
def poll_for_token(self, device_code, interval=5):
while True:
response = requests.post(f"{self.base_url}/api/oauth/device/token", json={
"device_code": device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code"
})
if response.status_code == 200:
data = response.json()
self.access_token = data["access_token"]
return data
elif response.status_code == 400:
error_data = response.json()
if error_data.get("error") == "authorization_pending":
time.sleep(interval)
continue
else:
raise Exception(f"Authorization failed: {error_data}")
else:
raise Exception(f"Unexpected response: {response.status_code}")
def connect_to_mcp(self):
if not self.access_token:
raise Exception("No access token available")
headers = {"Authorization": f"Bearer {self.access_token}"}
response = requests.post(f"{self.base_url}/api/sse", headers=headers, json={
"jsonrpc": "2.0",
"id": 1,
"method": "initialize"
})
return response.json()
# Usage
client = MCPClient("http://localhost:3000", "your_client_id")
# Discover the server
server_info = client.discover_server()
print("Server discovered:", server_info["name"])
# Request device authorization
auth_info = client.request_device_auth()
print(f"Please visit: {auth_info['verification_uri_complete']}")
# Poll for completion
token_info = client.poll_for_token(auth_info["device_code"])
print("Authorization completed!")
# Connect to MCP
mcp_response = client.connect_to_mcp()
print("MCP connection established!")
```
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
+8
View File
@@ -12,6 +12,7 @@
"@auth/prisma-adapter": "^2.9.1",
"@modelcontextprotocol/sdk": "^1.13.0",
"@prisma/client": "^6.10.1",
"crypto": "^1.0.1",
"next": "15.3.4",
"next-auth": "^5.0.0-beta.28",
"prisma": "^6.10.1",
@@ -1067,6 +1068,13 @@
"node": ">= 8"
}
},
"node_modules/crypto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz",
"integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==",
"deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.",
"license": "ISC"
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+1
View File
@@ -13,6 +13,7 @@
"@auth/prisma-adapter": "^2.9.1",
"@modelcontextprotocol/sdk": "^1.13.0",
"@prisma/client": "^6.10.1",
"crypto": "^1.0.1",
"next": "15.3.4",
"next-auth": "^5.0.0-beta.28",
"prisma": "^6.10.1",
@@ -1,20 +1,12 @@
-- CreateTable
CREATE TABLE "mcp_users" (
"email" VARCHAR NOT NULL,
"config" JSON,
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
CONSTRAINT "mcp_users_pk" PRIMARY KEY ("email")
);
-- CreateTable
CREATE TABLE "users" (
"email" VARCHAR NOT NULL,
"twitter_token" VARCHAR,
"linkedin_token" VARCHAR,
"bluesky_token" VARCHAR,
"mastodon_token" VARCHAR,
"linkedin_company" VARCHAR,
"utm_rules" JSON
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
@@ -45,17 +37,6 @@ CREATE TABLE "Session" (
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
@@ -64,36 +45,33 @@ CREATE TABLE "VerificationToken" (
);
-- CreateTable
CREATE TABLE "OAuthClient" (
CREATE TABLE "Client" (
"id" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"clientSecret" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"redirectUris" TEXT[],
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OAuthClient_pkey" PRIMARY KEY ("id")
CONSTRAINT "Client_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OAuthToken" (
CREATE TABLE "AccessToken" (
"id" TEXT NOT NULL,
"accessToken" TEXT NOT NULL,
"refreshToken" TEXT,
"clientId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"scope" TEXT[],
"clientId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OAuthToken_pkey" PRIMARY KEY ("id")
CONSTRAINT "AccessToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_idx" ON "users"("email");
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
@@ -101,23 +79,14 @@ CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provi
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "OAuthClient_clientId_key" ON "OAuthClient"("clientId");
CREATE UNIQUE INDEX "Client_clientId_key" ON "Client"("clientId");
-- CreateIndex
CREATE UNIQUE INDEX "OAuthToken_accessToken_key" ON "OAuthToken"("accessToken");
-- CreateIndex
CREATE UNIQUE INDEX "OAuthToken_refreshToken_key" ON "OAuthToken"("refreshToken");
CREATE UNIQUE INDEX "AccessToken_token_key" ON "AccessToken"("token");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -126,7 +95,10 @@ ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId"
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthClient" ADD CONSTRAINT "OAuthClient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Client" ADD CONSTRAINT "Client_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthToken" ADD CONSTRAINT "OAuthToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AccessToken" ADD CONSTRAINT "AccessToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccessToken" ADD CONSTRAINT "AccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "AuthCode" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"clientId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"redirectUri" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuthCode_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AuthCode_code_key" ON "AuthCode"("code");
-- AddForeignKey
ALTER TABLE "AuthCode" ADD CONSTRAINT "AuthCode_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuthCode" ADD CONSTRAINT "AuthCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Client" ALTER COLUMN "userId" DROP NOT NULL;
+50 -49
View File
@@ -8,19 +8,17 @@ datasource db {
url = env("DATABASE_URL")
}
model mcp_users {
email String @id(map: "mcp_users_pk") @db.VarChar
config Json? @db.Json
}
model users {
email String @unique(map: "users_email_idx") @db.VarChar
twitter_token String? @db.VarChar
linkedin_token String? @db.VarChar
bluesky_token String? @db.VarChar
mastodon_token String? @db.VarChar
linkedin_company String? @db.VarChar
utm_rules Json? @db.Json
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
clients Client[]
accessTokens AccessToken[]
authCodes AuthCode[]
}
model Account {
@@ -29,13 +27,13 @@ model Account {
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -50,47 +48,50 @@ model Session {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
clients OAuthClient[]
}
model VerificationToken {
identifier String
token String @unique
token String
expires DateTime
@@unique([identifier, token])
}
model OAuthClient {
model Client {
id String @id @default(cuid())
clientId String @unique
clientId String @unique @default(cuid())
clientSecret String
name String
description String?
redirectUris String[]
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tokens OAuthToken[]
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
accessTokens AccessToken[]
authCodes AuthCode[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OAuthToken {
id String @id @default(cuid())
accessToken String @unique
refreshToken String? @unique
clientId String
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
expiresAt DateTime
scope String[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
model AccessToken {
id String @id @default(cuid())
token String @unique
expiresAt DateTime
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
}
model AuthCode {
id String @id @default(cuid())
code String @unique
expiresAt DateTime
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
redirectUri String
createdAt DateTime @default(now())
}
+521
View File
@@ -0,0 +1,521 @@
# /// script
# dependencies = [
# "fastapi",
# "uvicorn",
# "pyjwt",
# "httpx",
# "python-multipart",
# "jinja2",
# "mcp>=1.9.4",
# "llama-cloud-services>=0.6.34",
# "pydantic>=2.8,!=2.10",
# ]
# ///
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Optional, cast
import httpx
import jwt
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, RedirectResponse
from llama_cloud import Project
from llama_cloud_services.extract.extract import AsyncLlamaCloud
from mcp import ServerSession
from mcp.server.auth.provider import (
AccessToken as MCPAccessToken,
)
from mcp.server.auth.provider import (
AuthorizationCode as MCPAuthorizationCode,
)
from mcp.server.auth.provider import (
AuthorizationParams,
OAuthAuthorizationServerProvider,
)
from mcp.server.auth.settings import (
AuthSettings,
ClientRegistrationOptions,
RevocationOptions,
)
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
from pydantic.v1 import BaseModel
# Configuration
OAUTH_SERVER_URL = "http://localhost:8000"
UPSTREAM_TEST_URL = "https://httpbin.org/bearer"
JWT_SECRET = "your-secret-key-change-in-production"
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# HTML template for API key input form
API_KEY_FORM_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>API Key Authorization</title>
<style>
body {{ font-family: Arial, sans-serif; max-width: 600px; margin: 100px auto; padding: 20px; }}
.form-container {{ background: #f5f5f5; padding: 30px; border-radius: 8px; }}
input[type="text"], input[type="password"] {{ width: 100%; padding: 12px; margin: 10px 0; border: 1px solid #ddd; border-radius: 4px; }}
button {{ background: #007cba; color: white; padding: 12px 24px; border: none; border-radius: 4px; cursor: pointer; }}
button:hover {{ background: #005a87; }}
h2 {{ color: #333; }}
.info {{ background: #e7f3ff; padding: 15px; border-radius: 4px; margin: 20px 0; }}
</style>
</head>
<body>
<div class="form-container">
<h2>🔐 API Key Authorization</h2>
<div class="info">
<p><strong>OAuth Application Authorization</strong></p>
<p>This application requires access to your API key to make authenticated requests on your behalf.</p>
</div>
<form method="post" action="/authorize">
<label for="api_key">Enter your API key:</label>
<input type="password" id="api_key" name="api_key" required placeholder="Enter your API key here...">
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
<input type="hidden" name="client_id" value="{client_id}">
<button type="submit">Authorize Application</button>
</form>
<p style="font-size: 12px; color: #666; margin-top: 20px;">
Your API key will be securely stored and used only for authenticated requests to the upstream service.
</p>
</div>
</body>
</html>
"""
# Create the main FastAPI application
app = FastAPI(
title="OAuth-from-API-Key Bridge Server",
description="Combined OAuth provider and MCP server for bridging OAuth apps with API key services",
version="0.1.0",
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class AccessToken(MCPAccessToken):
api_key: str
class AuthorizationCode(MCPAuthorizationCode):
api_key: str
JWT_DURATION_IN_SECONDS = 60 * 60 * 24 * 365 # 1 year
class FastAPIProvider(OAuthAuthorizationServerProvider):
"""OAuth provider that properly implements the MCP protocol"""
def __init__(self):
self.clients = {}
self.auth_codes = {}
self.access_tokens = {}
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
"""Get client information by client ID"""
logger.info(f"Getting client: {client_id}")
return self.clients.get(client_id)
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
"""Register a client"""
logger.info(f"Registering client: {client_info.client_id}")
self.clients[client_info.client_id] = client_info
async def authorize(
self, client: OAuthClientInformationFull, params: AuthorizationParams
) -> str:
"""Handle authorization - redirect to our OAuth form"""
logger.info(
f"Authorizing client {client.client_id} with redirect {params.redirect_uri}"
)
return f"{OAUTH_SERVER_URL}/authorize?client_id={client.client_id}&redirect_uri={params.redirect_uri}&response_type=code"
async def load_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: str
) -> AuthorizationCode | None:
"""Load authorization code"""
logger.info(f"Loading auth code: {authorization_code[:20]}...")
try:
payload = jwt.decode(authorization_code, JWT_SECRET, algorithms=["HS256"])
# Calculate expiration (1 hour from now)
expires_at = datetime.now(timezone.utc).timestamp() + 3600
return AuthorizationCode(
code=authorization_code,
scopes=["api:read"],
expires_at=expires_at,
client_id=client.client_id,
code_challenge="", # Not using PKCE for simplicity
redirect_uri=payload.get("redirect_uri"),
redirect_uri_provided_explicitly=True,
api_key=payload.get("api_key"),
)
except Exception as e:
logger.error(f"Failed to load auth code: {e}")
return None
async def exchange_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
) -> OAuthToken:
"""Exchange authorization code for access token"""
logger.info(f"Exchanging auth code for client {client.client_id}")
# Create access token with API key
access_payload = {
"api_key": authorization_code.api_key,
"exp": datetime.now(timezone.utc) + timedelta(days=365),
}
access_token = jwt.encode(access_payload, JWT_SECRET, algorithm="HS256")
# Store the token
expires_at = int(cast(datetime, access_payload["exp"]).timestamp())
# Calculate expires_in based on actual expiration time
current_time = int(datetime.now(timezone.utc).timestamp())
expires_in = max(0, expires_at - current_time)
token_obj = AccessToken(
token=access_token,
client_id=client.client_id,
scopes=["api:read"],
expires_at=expires_at,
api_key=authorization_code.api_key,
)
self.access_tokens[access_token] = token_obj
return OAuthToken(
access_token=access_token,
token_type="bearer",
expires_in=expires_in,
)
async def load_refresh_token(
self, client: OAuthClientInformationFull, refresh_token: str
) -> None:
"""Load refresh token (not implemented now)"""
return None
async def exchange_refresh_token(
self, client: OAuthClientInformationFull, refresh_token: Any, scopes: list[str]
) -> OAuthToken:
"""Exchange refresh token (not implemented now)"""
raise NotImplementedError("Refresh tokens not implemented")
async def load_access_token(self, token: str) -> AccessToken | None:
"""Load and validate access token - this is the key method!"""
logger.info(f"Loading access token: {token[:20]}...")
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
expires_at = payload.get("exp")
token_obj = AccessToken(
token=token,
client_id="default", # We'll use default for hackathon simplicity
scopes=["api:read"],
expires_at=expires_at,
api_key=payload.get("api_key", ""),
)
self.access_tokens[token] = token_obj
return token_obj
except jwt.ExpiredSignatureError:
logger.error("Token expired")
return None
except jwt.InvalidTokenError as e:
logger.error(f"Invalid token: {e}")
return None
async def revoke_token(self, token: Any) -> None:
"""Revoke a token"""
if hasattr(token, "token") and token.token in self.access_tokens:
del self.access_tokens[token.token]
logger.info("Token revoked")
# Create MCP server with OAuth authentication
mcp = FastMCP(
"OAuth MCP Server",
auth_server_provider=FastAPIProvider(),
auth=AuthSettings(
issuer_url=OAUTH_SERVER_URL,
revocation_options=RevocationOptions(enabled=False),
client_registration_options=ClientRegistrationOptions(
enabled=True,
valid_scopes=["api:read", "api:write"],
default_scopes=["api:read"],
),
required_scopes=["api:read"],
),
)
def get_api_key(ctx: Context[ServerSession, object, Request]) -> Optional[str]:
"""Get the API key from the authenticated token"""
if ctx.request_context.request:
auth_header = ctx.request_context.request.headers.get("authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:] # Remove "Bearer " prefix
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
return payload.get("api_key", None)
return None
@mcp.tool(description="Get the API key from the authenticated token")
async def list_projects(ctx: Context[ServerSession, object, Request]) -> str:
"""List projects available to the user"""
try:
ctx = mcp.get_context()
api_key = get_api_key(ctx)
if not api_key:
raise ValueError("No API key found")
client = AsyncLlamaCloud(token=api_key)
projects = await client.projects.list_projects()
return Projects(projects=projects).json()
except Exception as e:
logger.error(f"Tool error: {e}")
return f"Tool error: {str(e)}"
class Projects(BaseModel):
projects: list[Project]
@mcp.resource("config://server")
def get_server_config() -> str:
"""Get server configuration info"""
return f"Combined OAuth + MCP Server running on {OAUTH_SERVER_URL}"
# OAuth Discovery Endpoints
@app.get("/.well-known/oauth-authorization-server")
async def oauth_authorization_server():
"""OAuth 2.0 Authorization Server Metadata"""
return {
"issuer": OAUTH_SERVER_URL,
"authorization_endpoint": f"{OAUTH_SERVER_URL}/authorize",
"token_endpoint": f"{OAUTH_SERVER_URL}/token",
"registration_endpoint": f"{OAUTH_SERVER_URL}/register",
"scopes_supported": ["api:read", "api:write"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
"code_challenge_methods_supported": ["plain", "S256"],
}
@app.get("/.well-known/oauth-protected-resource")
async def oauth_protected_resource():
"""OAuth 2.0 Protected Resource Metadata"""
return {
"resource": f"{OAUTH_SERVER_URL}/test-mcp",
"authorization_servers": [OAUTH_SERVER_URL],
"scopes_supported": ["api:read", "api:write"],
"bearer_methods_supported": ["header"],
"resource_documentation": f"{OAUTH_SERVER_URL}/docs",
}
@app.post("/register")
async def register_client(request: Request):
"""OAuth 2.0 Dynamic Client Registration"""
body = await request.body()
logger.info(f"Client registration request: {body}")
try:
client_metadata = await request.json()
logger.info(f"Client metadata: {client_metadata}")
# Generate unique client credentials
import uuid
client_id = f"client_{uuid.uuid4().hex[:16]}"
client_secret = uuid.uuid4().hex
# Create client info object
from mcp.shared.auth import OAuthClientInformationFull
client_info = OAuthClientInformationFull(
client_id=client_id,
client_secret=client_secret,
client_name=client_metadata.get("client_name", "MCP Client"),
redirect_uris=client_metadata.get("redirect_uris", []),
grant_types=client_metadata.get("grant_types", ["authorization_code"]),
response_types=client_metadata.get("response_types", ["code"]),
token_endpoint_auth_method=client_metadata.get(
"token_endpoint_auth_method", "none"
),
)
# Actually register it with the provider
await mcp._auth_server_provider.register_client(client_info)
return {
"client_id": client_id,
"client_secret": client_secret,
"client_name": client_info.client_name,
"redirect_uris": client_info.redirect_uris,
"grant_types": client_info.grant_types,
"response_types": client_info.response_types,
"token_endpoint_auth_method": client_info.token_endpoint_auth_method,
}
except Exception as e:
logger.error(f"Client registration error: {e}")
raise HTTPException(400, f"Invalid client metadata: {e}")
# OAuth Authorization Endpoint
@app.get("/authorize")
async def get_authorize(
request: Request, redirect_uri: str, client_id: str = "default"
):
"""Show API key input form"""
logger.info(
f"Authorization request: redirect_uri={redirect_uri}, client_id={client_id}"
)
html_content = API_KEY_FORM_HTML.format(
redirect_uri=redirect_uri, client_id=client_id
)
return HTMLResponse(content=html_content)
@app.post("/authorize")
async def post_authorize(
api_key: str = Form(...), redirect_uri: str = Form(...), client_id: str = Form(...)
):
"""Process API key submission and redirect with auth code"""
logger.info(
f"Authorization POST: api_key=*****, redirect_uri={redirect_uri}, client_id={client_id}"
)
# Quick validation (allow through for hackathon speed)
try:
async with httpx.AsyncClient() as client:
headers = {"Authorization": f"Bearer {api_key}"}
await client.get(UPSTREAM_TEST_URL, headers=headers, timeout=5.0)
logger.info("API key validation successful")
except Exception as e:
logger.warning(f"API key validation failed (allowing through): {e}")
# Create auth code with API key embedded
payload = {
"api_key": api_key,
"redirect_uri": redirect_uri,
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
}
auth_code = jwt.encode(payload, JWT_SECRET, algorithm="HS256")
logger.info(f"Generated auth code, redirecting to: {redirect_uri}")
return RedirectResponse(f"{redirect_uri}?code={auth_code}")
# OAuth Token Endpoint
@app.post("/token")
async def exchange_token(request: Request):
"""Exchange authorization code for access token"""
# Log the raw request body for debugging
body = await request.body()
logger.info(f"Token exchange request body: {body}")
# Parse form data
form_data = await request.form()
grant_type = form_data.get("grant_type")
code = form_data.get("code")
client_id = form_data.get("client_id")
client_secret = form_data.get("client_secret")
logger.info(
f"Token exchange: grant_type={grant_type}, client_id={client_id}, code_preview={code[:20] if code else None}..."
)
if grant_type != "authorization_code":
raise HTTPException(400, "unsupported_grant_type")
try:
# Decode auth code to get API key
payload = jwt.decode(code, JWT_SECRET, algorithms=["HS256"])
api_key = payload["api_key"]
logger.info("Successfully decoded auth code and extracted API key")
# Create long-lived access token with API key
access_payload = {
"api_key": api_key,
"exp": datetime.now(timezone.utc) + timedelta(days=365),
}
access_token = jwt.encode(access_payload, JWT_SECRET, algorithm="HS256")
logger.info("Generated access token")
return {
"access_token": access_token,
"token_type": "bearer",
"expires_in": 31536000, # 1 year
}
except jwt.ExpiredSignatureError:
logger.error("Auth code expired")
raise HTTPException(400, "invalid_grant")
except Exception as e:
logger.error(f"Error exchanging token: {e}")
raise HTTPException(400, "invalid_grant")
# Root endpoint with service information
@app.get("/")
async def root():
"""Service information and endpoint listing"""
return {
"service": "OAuth-from-API-Key Bridge",
"description": "Bridges OAuth authentication with API key-based services",
"version": "0.1.0",
"endpoints": {
"oauth_authorize": "/authorize",
"oauth_token": "/token",
"mcp_server": "/sse",
"api_docs": "/docs",
},
"usage": "Connect MCP clients to /test-mcp endpoint. OAuth flow redirects to /authorize for API key input.",
}
# Mount MCP server using SSE transport which might be more stable
app.mount("/", mcp.sse_app())
def main():
"""Entry point for the server"""
import uvicorn
print("🎯 Starting OAuth-from-API-Key Bridge Server")
print("=" * 50)
print("📋 OAuth endpoints: /authorize, /token")
print("🔐 MCP endpoint: /test-mcp (streamable HTTP)")
print("📖 API docs: /docs")
print("🌐 Server: http://localhost:8000")
print("=" * 50)
# Use import string for hot reload support
uvicorn.run(
app,
host="0.0.0.0",
port=8000,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const baseUrl =
process.env.NEXTAUTH_URL ||
`${request.nextUrl.protocol}//${request.nextUrl.host}`;
const metadata = {
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/oauth/authorize`,
token_endpoint: `${baseUrl}/api/oauth/token`,
registration_endpoint: `${baseUrl}/api/oauth/register`,
scopes_supported: ["api:read", "api:write"],
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
token_endpoint_auth_methods_supported: ["none", "client_secret_post"],
code_challenge_methods_supported: ["plain", "S256"]
};
const response = NextResponse.json(metadata);
// Add CORS headers
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return response;
}
export async function OPTIONS(request: NextRequest) {
const response = new NextResponse("OK", { status: 200 });
// Add CORS headers for preflight requests
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return response;
}
@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const baseUrl =
process.env.NEXTAUTH_URL ||
`${request.nextUrl.protocol}//${request.nextUrl.host}`;
const metadata = {
resource: `${baseUrl}/sse`,
authorization_servers: [baseUrl],
scopes_supported: ["api:read", "api:write"],
bearer_methods_supported: ["header"],
resource_documentation: `${baseUrl}/docs`
};
const response = NextResponse.json(metadata);
// Add CORS headers
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return response;
}
export async function OPTIONS(request: NextRequest) {
const response = new NextResponse("OK", { status: 200 });
// Add CORS headers for preflight requests
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return response;
}
+65 -33
View File
@@ -1,41 +1,73 @@
import { NextRequest } from "next/server";
import { auth } from "@/app/auth";
import { createOAuthClient } from "@/app/auth";
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { prisma } from '@/app/prisma';
import { randomBytes } from 'crypto';
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
export async function POST(request: NextRequest) {
const body = await request.json();
const { client_name, redirect_uris } = body;
if (!client_name || !redirect_uris) {
const response = NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 },
);
// Add CORS headers
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Allow-Methods', 'POST, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return response;
}
const clientSecret = randomBytes(32).toString('hex');
try {
const body = await req.json();
const { name, description, redirectUris } = body;
if (!name) {
return Response.json({ error: "Name is required" }, { status: 400 });
}
const client = await createOAuthClient(
session.user.id,
name,
description,
redirectUris
);
return Response.json({
clientId: client.clientId,
clientSecret: client.clientSecret,
name: client.name,
description: client.description,
redirectUris: client.redirectUris,
const newClient = await prisma.client.create({
data: {
name: client_name,
redirectUris: redirect_uris,
clientSecret: clientSecret, // This should be hashed in a real app
userId: null, // Allow unauthenticated clients
},
});
} catch (error) {
console.error("Error registering OAuth client:", error);
return Response.json(
{ error: "Failed to register OAuth client" },
{ status: 500 }
const response = NextResponse.json({
client_id: newClient.clientId,
client_secret: clientSecret, // This is the only time the secret is sent
redirect_uris: redirect_uris,
});
// Add CORS headers
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Allow-Methods', 'POST, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return response;
} catch (e) {
console.error(e);
const response = NextResponse.json(
{ error: 'Error creating client' },
{ status: 500 },
);
// Add CORS headers
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Allow-Methods', 'POST, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return response;
}
}
export async function OPTIONS(request: NextRequest) {
const response = new NextResponse("OK", { status: 200 });
// Add CORS headers for preflight requests
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Allow-Methods', 'POST, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return response;
}
+115 -64
View File
@@ -1,70 +1,121 @@
import { NextRequest } from "next/server";
import { validateOAuthClient, createAccessToken, refreshAccessToken } from "@/app/auth";
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { prisma } from '@/app/prisma';
import { randomBytes } from 'crypto';
export async function POST(request: NextRequest) {
// Handle CORS preflight requests
if (request.method === 'OPTIONS') {
return new NextResponse("OK", {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
const formData = await request.formData();
const grant_type = formData.get('grant_type') as string;
const code = formData.get('code') as string;
const redirect_uri = formData.get('redirect_uri') as string;
const client_id = formData.get('client_id') as string;
const client_secret = formData.get('client_secret') as string;
if (grant_type !== 'authorization_code') {
return NextResponse.json({ error: 'Unsupported grant type' }, {
status: 400,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
});
}
if (!code || !redirect_uri || !client_id || !client_secret) {
return NextResponse.json({ error: 'Invalid request' }, {
status: 400,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
});
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { grant_type, client_id, client_secret, refresh_token, scope } = body;
if (!client_id || !client_secret) {
return Response.json(
{ error: "Client credentials are required" },
{ status: 400 }
);
}
// Validate the client
const client = await validateOAuthClient(client_id, client_secret);
if (!client) {
return Response.json({ error: "Invalid client" }, { status: 401 });
}
if (grant_type === "refresh_token") {
if (!refresh_token) {
return Response.json(
{ error: "Refresh token is required" },
{ status: 400 }
);
}
const newToken = await refreshAccessToken(refresh_token);
if (!newToken) {
return Response.json({ error: "Invalid refresh token" }, { status: 401 });
}
return Response.json({
access_token: newToken.accessToken,
refresh_token: newToken.refreshToken,
expires_in: Math.floor(
(newToken.expiresAt.getTime() - Date.now()) / 1000
),
token_type: "Bearer",
scope: newToken.scope,
const client = await prisma.client.findUnique({ where: { clientId: client_id } });
if (!client || client.clientSecret !== client_secret) {
return NextResponse.json({ error: 'Invalid client' }, {
status: 401,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
});
} else if (grant_type === "client_credentials") {
const token = await createAccessToken(
client_id,
scope ? scope.split(" ") : []
);
return Response.json({
access_token: token.accessToken,
refresh_token: token.refreshToken,
expires_in: Math.floor((token.expiresAt.getTime() - Date.now()) / 1000),
token_type: "Bearer",
scope: token.scope,
});
} else {
return Response.json(
{ error: "Unsupported grant type" },
{ status: 400 }
);
}
} catch (error) {
console.error("Error generating token:", error);
return Response.json(
{ error: "Failed to generate token" },
{ status: 500 }
);
const authCode = await prisma.authCode.findUnique({ where: { code } });
if (!authCode || authCode.clientId !== client.id || authCode.redirectUri !== redirect_uri) {
return NextResponse.json({ error: 'Invalid code' }, {
status: 400,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
});
}
if (authCode.expiresAt < new Date()) {
return NextResponse.json({ error: 'Code expired' }, {
status: 400,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
});
}
// Delete the auth code so it can't be used again
await prisma.authCode.delete({ where: { id: authCode.id } });
const accessToken = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await prisma.accessToken.create({
data: {
token: accessToken,
expiresAt,
clientId: client.id,
userId: authCode.userId,
},
});
return NextResponse.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
}, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
});
} catch (e) {
console.error(e);
return NextResponse.json({ error: 'Server error' }, {
status: 500,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
});
}
}
-34
View File
@@ -1,34 +0,0 @@
import { NextRequest } from "next/server";
import { validateAccessToken } from "@/app/auth";
export async function POST(req: NextRequest) {
try {
const authHeader = req.headers.get("authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return Response.json(
{ error: "Bearer token is required" },
{ status: 401 }
);
}
const token = authHeader.split(" ")[1];
const validToken = await validateAccessToken(token);
if (!validToken) {
return Response.json({ error: "Invalid token" }, { status: 401 });
}
return Response.json({
valid: true,
clientId: validToken.client.clientId,
scope: validToken.scope,
expiresAt: validToken.expiresAt,
});
} catch (error) {
console.error("Error validating token:", error);
return Response.json(
{ error: "Failed to validate token" },
{ status: 500 }
);
}
}
+2 -108
View File
@@ -1,10 +1,7 @@
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { PrismaClient } from "../generated/prisma";
import crypto from "crypto";
const prisma = new PrismaClient();
import { prisma } from './prisma';
export const {
handlers: { GET, POST },
@@ -19,108 +16,5 @@ export const {
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
pages: {
signIn: "/auth/signin",
},
callbacks: {
async session({ session, user }) {
return {
...session,
user: {
...session.user,
id: user.id,
},
};
},
},
callbacks: {},
});
// OAuth helper functions
export async function createOAuthClient(userId: string, name: string, description?: string, redirectUris: string[] = []) {
const clientId = crypto.randomBytes(16).toString("hex");
const clientSecret = crypto.randomBytes(32).toString("hex");
return prisma.oAuthClient.create({
data: {
clientId,
clientSecret,
name,
description,
redirectUris,
userId,
},
});
}
export async function validateOAuthClient(clientId: string, clientSecret: string) {
const client = await prisma.oAuthClient.findUnique({
where: { clientId },
});
if (!client || client.clientSecret !== clientSecret) {
return null;
}
return client;
}
export async function createAccessToken(clientId: string, scope: string[] = []) {
const client = await prisma.oAuthClient.findUnique({
where: { clientId },
});
if (!client) {
throw new Error("Invalid client");
}
const accessToken = crypto.randomBytes(32).toString("hex");
const refreshToken = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 3600 * 1000); // 1 hour from now
return prisma.oAuthToken.create({
data: {
accessToken,
refreshToken,
clientId: client.id,
expiresAt,
scope,
},
});
}
export async function validateAccessToken(accessToken: string) {
const token = await prisma.oAuthToken.findUnique({
where: { accessToken },
include: { client: true },
});
if (!token || token.expiresAt < new Date()) {
return null;
}
return token;
}
export async function refreshAccessToken(refreshToken: string) {
const token = await prisma.oAuthToken.findUnique({
where: { refreshToken },
include: { client: true },
});
if (!token) {
return null;
}
const newAccessToken = crypto.randomBytes(32).toString("hex");
const newRefreshToken = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 3600 * 1000); // 1 hour from now
return prisma.oAuthToken.update({
where: { id: token.id },
data: {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiresAt,
},
});
}
+86
View File
@@ -87,3 +87,89 @@ a {
.button-signout:hover {
background-color: var(--danger-hover);
}
/* Device Authorization Styles */
.device-auth-form {
width: 100%;
max-width: 500px;
}
.input-group {
display: flex;
gap: 0.5rem;
margin: 1rem 0;
}
.input {
flex: 1;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
}
.input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.button-primary {
background-color: var(--primary-color);
color: white;
}
.button-primary:hover {
background-color: var(--primary-hover);
}
.button-primary:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
.button-secondary {
background-color: #6b7280;
color: white;
}
.button-secondary:hover {
background-color: #4b5563;
}
.button-group {
display: flex;
gap: 0.5rem;
margin: 1rem 0;
}
.device-auth-details {
width: 100%;
max-width: 600px;
}
.device-info {
background-color: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
margin: 1rem 0;
}
.device-info p {
margin: 0.5rem 0;
}
.auth-actions {
margin: 1rem 0;
}
.success-message {
text-align: center;
color: #059669;
}
.error {
color: var(--danger-color);
margin: 0.5rem 0;
}
+145
View File
@@ -0,0 +1,145 @@
import { auth } from '@/app/auth';
import { prisma } from '@/app/prisma';
import { redirect } from 'next/navigation';
import { randomBytes } from 'crypto';
import { headers } from 'next/headers';
export default async function AuthorizePage({
searchParams,
}: {
searchParams: { [key: string]: string | string[] | undefined };
}) {
const session = await auth();
const clientId = searchParams.client_id as string;
const redirectUri = searchParams.redirect_uri as string;
const responseType = searchParams.response_type as string;
const state = searchParams.state as string;
if (!session || !session.user || !session.user.id) {
const headersList = headers();
const host = headersList.get('host');
const prot = process.env.NODE_ENV === 'production' ? 'https' : 'http';
const baseUrl = `${prot}://${host}`;
const loginUrl = new URL('/api/auth/signin', baseUrl);
const callbackUrl = new URL('/oauth/authorize', baseUrl);
// Add all current search params to the callback URL
Object.entries(searchParams).forEach(([key, value]) => {
if (typeof value === 'string') {
callbackUrl.searchParams.set(key, value);
}
});
loginUrl.searchParams.set('callbackUrl', callbackUrl.toString());
return redirect(loginUrl.toString());
}
if (!clientId || !redirectUri || responseType !== 'code') {
return (
<main className="flex items-center justify-center h-screen">
<div className="bg-white p-8 rounded-lg shadow-md max-w-sm w-full text-center">
<h1 className="text-2xl font-bold mb-4">Error</h1>
<p>Invalid authorization request.</p>
<p className="text-xs text-gray-500 mt-4">
Missing client_id, redirect_uri, or response_type is not 'code'.
</p>
</div>
</main>
);
}
const client = await prisma.client.findUnique({
where: { clientId },
});
if (!client || !client.redirectUris.includes(redirectUri)) {
return (
<main className="flex items-center justify-center h-screen">
<div className="bg-white p-8 rounded-lg shadow-md max-w-sm w-full text-center">
<h1 className="text-2xl font-bold mb-4">Error</h1>
<p>Invalid client or redirect URI.</p>
</div>
</main>
);
}
async function handleConsent(formData: FormData) {
'use server';
const session = await auth();
if (!session?.user?.id) {
// This should not be reachable if the user sees the consent screen
throw new Error('No session found during consent handling.');
}
const consent = formData.get('consent');
const redirectUrl = new URL(redirectUri);
if (state) {
redirectUrl.searchParams.set('state', state);
}
if (consent === 'deny') {
redirectUrl.searchParams.set('error', 'access_denied');
return redirect(redirectUrl.toString());
}
const authorizationCode = randomBytes(16).toString('hex');
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
await prisma.authCode.create({
data: {
code: authorizationCode,
expiresAt,
clientId: client.id,
userId: session.user.id,
redirectUri: redirectUri,
},
});
redirectUrl.searchParams.set('code', authorizationCode);
redirect(redirectUrl.toString());
}
return (
<main className="flex items-center justify-center h-screen bg-gray-50">
<div className="bg-white p-8 rounded-lg shadow-md max-w-sm w-full">
<h1 className="text-xl font-semibold mb-4 text-center">
Authorize Application
</h1>
<div className="text-center">
<p className="mb-2">
The application{' '}
<strong className="font-medium">{client.name}</strong> is
requesting access to your account.
</p>
<p className="text-sm text-gray-600">
Do you want to grant access?
</p>
</div>
<form action={handleConsent} className="mt-6">
<div className="flex justify-center gap-4">
<button
type="submit"
name="consent"
value="allow"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
>
Allow
</button>
<button
type="submit"
name="consent"
value="deny"
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-opacity-50"
>
Deny
</button>
</div>
</form>
</div>
</main>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { PrismaClient } from '@/generated/prisma';
// PrismaClient is attached to the `global` object in development to prevent
// exhausting your database connection limit.
//
// Learn more: https://pris.ly/d/help/next-js-best-practices
const prisma = global.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') global.prisma = prisma;
export { prisma };
declare global {
var prisma: PrismaClient | undefined;
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { prisma } from '@/app/prisma';
export async function GET(request: NextRequest) {
const authHeader = request.headers.get('authorization');
if (!authHeader) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const token = authHeader.split(' ')[1];
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const accessToken = await prisma.accessToken.findUnique({
where: { token },
});
if (!accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (accessToken.expiresAt < new Date()) {
return NextResponse.json({ error: 'Token expired' }, { status: 401 });
}
return NextResponse.json({
message: 'Success! This is a protected resource.',
});
} catch (e) {
console.error(e);
return NextResponse.json(
{ error: 'Error validating token' },
{ status: 500 },
);
}
}