mirror of
https://github.com/run-llama/mcp-nextjs.git
synced 2026-07-01 21:54:01 -04:00
Successful oauth flow
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+22
-50
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user