Support Codex OAuth login and add CLI setup docs
This commit is contained in:
162
AGENTS_CLI_SETUP.md
Normal file
162
AGENTS_CLI_SETUP.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# Connecting Hermes MCP to CLI Agents
|
||||||
|
|
||||||
|
Hermes exposes a Streamable HTTP MCP endpoint at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://hermes.squaremcp.com/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy SSE is also available at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://hermes.squaremcp.com/sse
|
||||||
|
```
|
||||||
|
|
||||||
|
Use your own credential value anywhere you see:
|
||||||
|
|
||||||
|
```text
|
||||||
|
YOUR_MCP_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
Never commit a live API key into repo config files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Claude Code
|
||||||
|
|
||||||
|
Some Claude Code builds support MCP add/list commands and some require manual config.
|
||||||
|
|
||||||
|
### If your Claude CLI supports `mcp add`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add hermes \
|
||||||
|
--transport http \
|
||||||
|
https://hermes.squaremcp.com/mcp \
|
||||||
|
--header "x-api-key: YOUR_MCP_API_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual config
|
||||||
|
|
||||||
|
If your version does not support the CLI flow, add Hermes to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.claude/mcp.json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"hermes": {
|
||||||
|
"url": "https://hermes.squaremcp.com/mcp",
|
||||||
|
"headers": {
|
||||||
|
"x-api-key": "YOUR_MCP_API_KEY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp list
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Claude Desktop
|
||||||
|
|
||||||
|
Add Hermes to the Claude Desktop config:
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/Library/Application Support/Claude/claude_desktop_config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.config/Claude/claude_desktop_config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
```text
|
||||||
|
%APPDATA%\Claude\claude_desktop_config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"hermes": {
|
||||||
|
"url": "https://hermes.squaremcp.com/mcp",
|
||||||
|
"headers": {
|
||||||
|
"x-api-key": "YOUR_MCP_API_KEY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart Claude Desktop after saving the file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generic MCP-capable CLI agents
|
||||||
|
|
||||||
|
Many agent CLIs and editor plugins accept a JSON config file with an `mcpServers` block.
|
||||||
|
|
||||||
|
Example portable config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"hermes": {
|
||||||
|
"url": "https://hermes.squaremcp.com/mcp",
|
||||||
|
"headers": {
|
||||||
|
"x-api-key": "YOUR_MCP_API_KEY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical usage pattern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
some-agent-cli --mcp-config-file ~/.mcp.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Possible target clients include:
|
||||||
|
|
||||||
|
1. custom MCP wrappers
|
||||||
|
2. editor plugins that expose MCP server config
|
||||||
|
3. internal agent CLIs that consume a shared JSON config
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SSE fallback
|
||||||
|
|
||||||
|
If a client only supports SSE:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"hermes": {
|
||||||
|
"url": "https://hermes.squaremcp.com/sse",
|
||||||
|
"headers": {
|
||||||
|
"x-api-key": "YOUR_MCP_API_KEY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
1. [Codex CLI setup](./CODEX_SETUP.md)
|
||||||
|
2. [opencode setup](./OPENCODE.md)
|
||||||
|
3. [ChatGPT setup](./CHATGPT_SETUP.md)
|
||||||
58
CODEX_SETUP.md
Normal file
58
CODEX_SETUP.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Connecting Hermes MCP to Codex CLI
|
||||||
|
|
||||||
|
Hermes is exposed for Codex at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://hermes.squaremcp.com/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended setup
|
||||||
|
|
||||||
|
Add this block to your global Codex config at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.codex/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[mcp_servers.hermes]
|
||||||
|
url = "https://hermes.squaremcp.com/mcp"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the file already exists, append or merge the block instead of replacing your other config.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[projects."/path/to/your/project"]
|
||||||
|
trust_level = "trusted"
|
||||||
|
|
||||||
|
[mcp_servers.hermes]
|
||||||
|
url = "https://hermes.squaremcp.com/mcp"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth model
|
||||||
|
|
||||||
|
Hermes supports:
|
||||||
|
|
||||||
|
1. `x-api-key`
|
||||||
|
2. `?key=`
|
||||||
|
3. OAuth bearer tokens
|
||||||
|
|
||||||
|
Codex config currently uses the server URL block above. If your Codex build later exposes MCP header configuration directly, use your own live credential and do not commit it into this repo.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
After updating `~/.codex/config.toml`, restart Codex and check that the `hermes` MCP server is available in the tool space for the project.
|
||||||
|
|
||||||
|
## Current production endpoint
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://hermes.squaremcp.com/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
1. [CLI agent setup](./AGENTS_CLI_SETUP.md)
|
||||||
|
2. [opencode setup](./OPENCODE.md)
|
||||||
|
3. [ChatGPT setup](./CHATGPT_SETUP.md)
|
||||||
210
README.md
210
README.md
@@ -1,129 +1,141 @@
|
|||||||
# Hermes MCP
|
# Hermes MCP
|
||||||
|
|
||||||
A multi-account email MCP server for [Claude AI](https://claude.ai).
|
Hermes MCP is a hosted MCP gateway for messaging, knowledge, and social connectors.
|
||||||
Supports **Yahoo Mail** (IMAP App Password) and any **self-hosted mail server** (IMAP/SMTP).
|
|
||||||
|
The production endpoint is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://hermes.squaremcp.com/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Hermes currently supports MCP access patterns for:
|
||||||
|
|
||||||
|
1. email
|
||||||
|
2. Obsidian vault notes
|
||||||
|
3. WhatsApp Business
|
||||||
|
4. LinkedIn
|
||||||
|
5. Telegram
|
||||||
|
6. additional social connectors that are in various states of credentialing and rollout
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## What Hermes is for
|
||||||
|
|
||||||
- Read, search, and send email from Claude via MCP
|
Hermes is the integration and connector layer behind broader product work such as SquareMCP.
|
||||||
- Multi-account support: Yahoo Mail and custom IMAP/SMTP servers
|
|
||||||
- Streamable HTTP transport (MCP 1.x) + legacy SSE endpoint
|
Use Hermes when you want:
|
||||||
- Automatic session recovery after server restarts
|
|
||||||
- Docker and Kubernetes deployment ready
|
1. an MCP endpoint that exposes real tools over Streamable HTTP
|
||||||
|
2. API-key or OAuth-protected access
|
||||||
|
3. connector access from agent clients such as Codex CLI, Claude Code, opencode, or ChatGPT
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tools
|
## Core transports
|
||||||
|
|
||||||
| Tool | Description | Key params |
|
| Transport | URL |
|
||||||
|------|-------------|------------|
|
|----------|-----|
|
||||||
| `get_profile` | Get email address for an account | `account` |
|
| Streamable HTTP (preferred) | `https://hermes.squaremcp.com/mcp` |
|
||||||
| `search_messages` | Search INBOX by keyword / sender / subject | `q`, `maxResults`, `account` |
|
| Legacy SSE | `https://hermes.squaremcp.com/sse` |
|
||||||
| `read_message` | Read full message body by UID | `uid`, `account` |
|
|
||||||
| `list_folders` | List all mailbox folders | `account` |
|
|
||||||
| `create_draft` | Save a draft to the Drafts folder | `to`, `subject`, `body`, `account` |
|
|
||||||
| `send_email` | Send an email | `to`, `subject`, `body`, `account` |
|
|
||||||
|
|
||||||
`account` parameter allows you to specify which configured mailbox to use (defaults to `"yahoo"`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start (local dev)
|
## Authentication
|
||||||
|
|
||||||
|
Hermes accepts:
|
||||||
|
|
||||||
|
1. `x-api-key` header
|
||||||
|
2. `?key=` query parameter
|
||||||
|
3. `Authorization: Bearer ...` for OAuth-based clients
|
||||||
|
|
||||||
|
For local/manual config examples in this repo, always substitute your own value for:
|
||||||
|
|
||||||
|
```text
|
||||||
|
YOUR_MCP_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not commit live API keys into repo config files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client setup guides
|
||||||
|
|
||||||
|
Use the setup guide that matches your client:
|
||||||
|
|
||||||
|
1. [Codex CLI setup](./CODEX_SETUP.md)
|
||||||
|
2. [CLI agent setup (Claude Code, generic MCP CLIs, Claude Desktop)](./AGENTS_CLI_SETUP.md)
|
||||||
|
3. [opencode setup](./OPENCODE.md)
|
||||||
|
4. [ChatGPT Custom GPT setup](./CHATGPT_SETUP.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Codex quick setup
|
||||||
|
|
||||||
|
Add this block to `~/.codex/config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[mcp_servers.hermes]
|
||||||
|
url = "https://hermes.squaremcp.com/mcp"
|
||||||
|
```
|
||||||
|
|
||||||
|
See [CODEX_SETUP.md](./CODEX_SETUP.md) for the full notes and caveats.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## opencode quick setup
|
||||||
|
|
||||||
|
Project-level `opencode.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
|
"hermes": {
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://hermes.squaremcp.com/mcp",
|
||||||
|
"headers": {
|
||||||
|
"x-api-key": "YOUR_MCP_API_KEY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Full instructions: [OPENCODE.md](./OPENCODE.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Install dependencies
|
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 2. Configure credentials
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env with your email credentials
|
|
||||||
|
|
||||||
# 3. Run in dev mode
|
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# 4. Verify
|
|
||||||
curl http://localhost:3456/health
|
curl http://localhost:3456/health
|
||||||
# → {"status":"ok","service":"hermes-mcp"}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
The local server runs on port `3456` by default.
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Copy `.env.example` to `.env` and fill in your values:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Yahoo Mail — generate an App Password at:
|
|
||||||
# https://myaccount.yahoo.com/security → App passwords
|
|
||||||
YAHOO_EMAIL=you@yahoo.com
|
|
||||||
YAHOO_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
|
||||||
|
|
||||||
# Optional: Self-hosted mail server (any IMAP/SMTP server)
|
|
||||||
# CUSTOM_EMAIL=you@yourdomain.com
|
|
||||||
# CUSTOM_PASSWORD=yourpassword
|
|
||||||
# CUSTOM_IMAP_HOST=mail.yourdomain.com
|
|
||||||
# CUSTOM_IMAP_PORT=993
|
|
||||||
# CUSTOM_SMTP_HOST=mail.yourdomain.com
|
|
||||||
# CUSTOM_SMTP_PORT=587
|
|
||||||
|
|
||||||
PORT=3456
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Connecting to Claude.ai
|
## Deployment
|
||||||
|
|
||||||
1. Go to **Claude.ai → Settings → Connectors → Add custom connector**
|
Production deployment notes are in:
|
||||||
2. Enter your server URL: `https://your-domain.com/mcp`
|
|
||||||
3. Click **Connect**
|
1. [DEPLOY.md](./DEPLOY.md)
|
||||||
|
2. `hermes-k8s.yaml`
|
||||||
|
|
||||||
|
SquareMCP product-site docs live under:
|
||||||
|
|
||||||
|
1. [`product/site`](./product/site)
|
||||||
|
2. [`product/README.md`](./product/README.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Production Deployment (Kubernetes)
|
## Notes
|
||||||
|
|
||||||
See [`DEPLOY.md`](./DEPLOY.md) for full instructions covering:
|
The historical docs in this repo started from an email-only Claude-focused setup. Current deployment and setup guidance should follow:
|
||||||
|
|
||||||
- MicroK8s setup with nginx-ingress and cert-manager
|
1. the `hermes.squaremcp.com` domain
|
||||||
- Building and pushing a Docker image to the local registry
|
2. Streamable HTTP `/mcp` as the default transport
|
||||||
- Applying the Kubernetes Deployment / Service / Ingress manifests
|
3. the dedicated client setup docs linked above
|
||||||
- Zero-downtime redeploys after code changes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Claude.ai ──POST /mcp──► StreamableHTTPServerTransport
|
|
||||||
│
|
|
||||||
┌─────────▼──────────┐
|
|
||||||
│ MCP Server (SDK) │
|
|
||||||
│ tools / handlers │
|
|
||||||
└──┬──────────────┬───┘
|
|
||||||
│ │
|
|
||||||
imapflow (IMAP) nodemailer (SMTP)
|
|
||||||
│ │
|
|
||||||
┌────────▼───┐ ┌───────▼───────┐
|
|
||||||
│ Yahoo Mail │ │ Custom IMAP/ │
|
|
||||||
│ │ │ SMTP Server │
|
|
||||||
│ │ │ (optional) │
|
|
||||||
└────────────┘ └───────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Runtime:** Node.js + TypeScript
|
|
||||||
- **MCP SDK:** `@modelcontextprotocol/sdk`
|
|
||||||
- **IMAP:** `imapflow`
|
|
||||||
- **SMTP:** `nodemailer`
|
|
||||||
- **HTTP:** `express`
|
|
||||||
- **Deployment:** Docker + MicroK8s
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
|
|||||||
31
src/db.ts
31
src/db.ts
@@ -7,6 +7,30 @@ const password = process.env.MYSQL_PASSWORD || '';
|
|||||||
|
|
||||||
let pool: mysql.Pool | null = null;
|
let pool: mysql.Pool | null = null;
|
||||||
|
|
||||||
|
async function ensureColumn(
|
||||||
|
db: mysql.PoolConnection,
|
||||||
|
tableName: string,
|
||||||
|
columnName: string,
|
||||||
|
definition: string
|
||||||
|
): Promise<void> {
|
||||||
|
const [rows] = await db.execute<mysql.RowDataPacket[]>(
|
||||||
|
`
|
||||||
|
SELECT COLUMN_NAME
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = ?
|
||||||
|
AND COLUMN_NAME = ?
|
||||||
|
`,
|
||||||
|
[tableName, columnName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(rows) && rows.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${definition}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function getPool(): mysql.Pool {
|
export function getPool(): mysql.Pool {
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
throw new Error('Database pool not initialized. Call initDatabase() first.');
|
throw new Error('Database pool not initialized. Call initDatabase() first.');
|
||||||
@@ -57,12 +81,19 @@ export async function initDatabase(): Promise<void> {
|
|||||||
code VARCHAR(255) PRIMARY KEY,
|
code VARCHAR(255) PRIMARY KEY,
|
||||||
client_id VARCHAR(255),
|
client_id VARCHAR(255),
|
||||||
redirect_uri TEXT,
|
redirect_uri TEXT,
|
||||||
|
scope TEXT NULL,
|
||||||
|
code_challenge TEXT NULL,
|
||||||
|
code_challenge_method VARCHAR(20) NULL,
|
||||||
expires_at TIMESTAMP,
|
expires_at TIMESTAMP,
|
||||||
used BOOLEAN DEFAULT FALSE,
|
used BOOLEAN DEFAULT FALSE,
|
||||||
INDEX idx_expires (expires_at)
|
INDEX idx_expires (expires_at)
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await ensureColumn(db, 'oauth_auth_codes', 'scope', 'TEXT NULL');
|
||||||
|
await ensureColumn(db, 'oauth_auth_codes', 'code_challenge', 'TEXT NULL');
|
||||||
|
await ensureColumn(db, 'oauth_auth_codes', 'code_challenge_method', 'VARCHAR(20) NULL');
|
||||||
|
|
||||||
await db.execute(`
|
await db.execute(`
|
||||||
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||||
token VARCHAR(255) PRIMARY KEY,
|
token VARCHAR(255) PRIMARY KEY,
|
||||||
|
|||||||
50
src/index.ts
50
src/index.ts
@@ -165,6 +165,28 @@ function extractBearerToken(req: express.Request): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractBasicClientCredentials(req: express.Request): { clientId?: string; clientSecret?: string } {
|
||||||
|
const authHeader = req.headers.authorization as string | undefined;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8');
|
||||||
|
const separatorIndex = decoded.indexOf(':');
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientId: decoded.slice(0, separatorIndex),
|
||||||
|
clientSecret: decoded.slice(separatorIndex + 1),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function requireAuth(req: express.Request, res: express.Response, next: express.NextFunction) {
|
async function requireAuth(req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||||
try {
|
try {
|
||||||
// No API key configured = open access
|
// No API key configured = open access
|
||||||
@@ -236,6 +258,8 @@ app.get('/oauth/authorize', async (req, res) => {
|
|||||||
const redirectUri = req.query.redirect_uri as string | undefined;
|
const redirectUri = req.query.redirect_uri as string | undefined;
|
||||||
const state = req.query.state as string | undefined;
|
const state = req.query.state as string | undefined;
|
||||||
const scope = req.query.scope as string | undefined;
|
const scope = req.query.scope as string | undefined;
|
||||||
|
const codeChallenge = req.query.code_challenge as string | undefined;
|
||||||
|
const codeChallengeMethod = req.query.code_challenge_method as string | undefined;
|
||||||
const responseType = req.query.response_type as string | undefined;
|
const responseType = req.query.response_type as string | undefined;
|
||||||
|
|
||||||
if (!clientId || !redirectUri) {
|
if (!clientId || !redirectUri) {
|
||||||
@@ -254,7 +278,14 @@ app.get('/oauth/authorize', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/html');
|
res.setHeader('Content-Type', 'text/html');
|
||||||
res.send(getAuthorizeHtml({ client_id: clientId, redirect_uri: redirectUri, state, scope }));
|
res.send(getAuthorizeHtml({
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
state,
|
||||||
|
scope,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: codeChallengeMethod,
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/oauth/authorize', async (req, res) => {
|
app.post('/oauth/authorize', async (req, res) => {
|
||||||
@@ -262,6 +293,8 @@ app.post('/oauth/authorize', async (req, res) => {
|
|||||||
const redirectUri = req.body.redirect_uri as string | undefined;
|
const redirectUri = req.body.redirect_uri as string | undefined;
|
||||||
const state = req.body.state as string | undefined;
|
const state = req.body.state as string | undefined;
|
||||||
const scope = req.body.scope as string | undefined;
|
const scope = req.body.scope as string | undefined;
|
||||||
|
const codeChallenge = req.body.code_challenge as string | undefined;
|
||||||
|
const codeChallengeMethod = req.body.code_challenge_method as string | undefined;
|
||||||
const action = req.body.action as string | undefined;
|
const action = req.body.action as string | undefined;
|
||||||
|
|
||||||
if (!clientId || !redirectUri) {
|
if (!clientId || !redirectUri) {
|
||||||
@@ -284,7 +317,7 @@ app.post('/oauth/authorize', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = await createAuthCode(clientId, redirectUri, scope);
|
const code = await createAuthCode(clientId, redirectUri, scope, codeChallenge, codeChallengeMethod);
|
||||||
const url = new URL(redirectUri);
|
const url = new URL(redirectUri);
|
||||||
url.searchParams.set('code', code.code);
|
url.searchParams.set('code', code.code);
|
||||||
if (state) url.searchParams.set('state', state);
|
if (state) url.searchParams.set('state', state);
|
||||||
@@ -299,17 +332,19 @@ app.post('/oauth/token', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientId = req.body.client_id as string | undefined;
|
const basicAuth = extractBasicClientCredentials(req);
|
||||||
const clientSecret = req.body.client_secret as string | undefined;
|
const clientId = (req.body.client_id as string | undefined) || basicAuth.clientId;
|
||||||
|
const clientSecret = (req.body.client_secret as string | undefined) || basicAuth.clientSecret;
|
||||||
const code = req.body.code as string | undefined;
|
const code = req.body.code as string | undefined;
|
||||||
const redirectUri = req.body.redirect_uri as string | undefined;
|
const redirectUri = req.body.redirect_uri as string | undefined;
|
||||||
|
const codeVerifier = req.body.code_verifier as string | undefined;
|
||||||
|
|
||||||
if (!clientId || !clientSecret || !code || !redirectUri) {
|
if (!clientId || !code || !redirectUri || (!clientSecret && !codeVerifier)) {
|
||||||
res.status(400).json({ error: 'invalid_request' });
|
res.status(400).json({ error: 'invalid_request' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await exchangeCodeForToken(clientId, clientSecret, code, redirectUri);
|
const token = await exchangeCodeForToken(clientId, clientSecret, code, redirectUri, codeVerifier);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
res.status(400).json({ error: 'invalid_grant' });
|
res.status(400).json({ error: 'invalid_grant' });
|
||||||
return;
|
return;
|
||||||
@@ -973,7 +1008,8 @@ const oauthDiscovery = {
|
|||||||
registration_endpoint: `${SERVER_URL}/oauth/register`,
|
registration_endpoint: `${SERVER_URL}/oauth/register`,
|
||||||
response_types_supported: ['code'],
|
response_types_supported: ['code'],
|
||||||
grant_types_supported: ['authorization_code'],
|
grant_types_supported: ['authorization_code'],
|
||||||
token_endpoint_auth_methods_supported: ['client_secret_post'],
|
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic', 'none'],
|
||||||
|
code_challenge_methods_supported: ['S256', 'plain'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const protectedResourceMetadata = {
|
const protectedResourceMetadata = {
|
||||||
|
|||||||
60
src/oauth.ts
60
src/oauth.ts
@@ -18,6 +18,8 @@ interface AuthCode {
|
|||||||
client_id: string;
|
client_id: string;
|
||||||
redirect_uri: string;
|
redirect_uri: string;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
|
code_challenge?: string;
|
||||||
|
code_challenge_method?: string;
|
||||||
expires_at: number;
|
expires_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +59,19 @@ export function generateAccessToken(): string {
|
|||||||
return crypto.randomBytes(32).toString('hex');
|
return crypto.randomBytes(32).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function verifyPkce(codeVerifier: string, storedChallenge: string, method?: string): boolean {
|
||||||
|
if (!method || method === 'plain') {
|
||||||
|
return codeVerifier === storedChallenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'S256') {
|
||||||
|
const hashed = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
||||||
|
return hashed === storedChallenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export async function registerClient(body: {
|
export async function registerClient(body: {
|
||||||
client_name?: string;
|
client_name?: string;
|
||||||
redirect_uris?: string[];
|
redirect_uris?: string[];
|
||||||
@@ -122,20 +137,24 @@ export async function getClient(clientId: string): Promise<Client | undefined> {
|
|||||||
export async function createAuthCode(
|
export async function createAuthCode(
|
||||||
clientId: string,
|
clientId: string,
|
||||||
redirectUri: string,
|
redirectUri: string,
|
||||||
scope?: string
|
scope?: string,
|
||||||
|
codeChallenge?: string,
|
||||||
|
codeChallengeMethod?: string
|
||||||
): Promise<AuthCode> {
|
): Promise<AuthCode> {
|
||||||
const code: AuthCode = {
|
const code: AuthCode = {
|
||||||
code: generateAuthCode(),
|
code: generateAuthCode(),
|
||||||
client_id: clientId,
|
client_id: clientId,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
scope,
|
scope,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: codeChallengeMethod,
|
||||||
expires_at: Date.now() + AUTH_CODE_EXPIRY_MS,
|
expires_at: Date.now() + AUTH_CODE_EXPIRY_MS,
|
||||||
};
|
};
|
||||||
|
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
await pool.execute(
|
await pool.execute(
|
||||||
'INSERT INTO oauth_auth_codes (code, client_id, redirect_uri, expires_at) VALUES (?, ?, ?, ?)',
|
'INSERT INTO oauth_auth_codes (code, client_id, redirect_uri, scope, code_challenge, code_challenge_method, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
[code.code, clientId, redirectUri, new Date(code.expires_at)]
|
[code.code, clientId, redirectUri, scope || null, codeChallenge || null, codeChallengeMethod || null, new Date(code.expires_at)]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`[oauth] Created auth code ${code.code.slice(0, 8)}... for client ${clientId}`);
|
console.log(`[oauth] Created auth code ${code.code.slice(0, 8)}... for client ${clientId}`);
|
||||||
@@ -144,9 +163,10 @@ export async function createAuthCode(
|
|||||||
|
|
||||||
export async function exchangeCodeForToken(
|
export async function exchangeCodeForToken(
|
||||||
clientId: string,
|
clientId: string,
|
||||||
clientSecret: string,
|
clientSecret: string | undefined,
|
||||||
code: string,
|
code: string,
|
||||||
redirectUri: string
|
redirectUri: string,
|
||||||
|
codeVerifier?: string
|
||||||
): Promise<Token | null> {
|
): Promise<Token | null> {
|
||||||
let client: Client | undefined;
|
let client: Client | undefined;
|
||||||
try {
|
try {
|
||||||
@@ -156,7 +176,17 @@ export async function exchangeCodeForToken(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!client || client.client_secret !== clientSecret) {
|
if (!client) {
|
||||||
|
console.log('[oauth] Invalid client');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientSecret) {
|
||||||
|
if (client.client_secret !== clientSecret) {
|
||||||
|
console.log('[oauth] Invalid client credentials');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if (!codeVerifier) {
|
||||||
console.log('[oauth] Invalid client credentials');
|
console.log('[oauth] Invalid client credentials');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -180,6 +210,18 @@ export async function exchangeCodeForToken(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authCode.code_challenge) {
|
||||||
|
if (!codeVerifier) {
|
||||||
|
console.log('[oauth] Missing code_verifier for PKCE exchange');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyPkce(codeVerifier, authCode.code_challenge, authCode.code_challenge_method || undefined)) {
|
||||||
|
console.log('[oauth] PKCE verification failed');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mark auth code as used
|
// Mark auth code as used
|
||||||
await db.execute('UPDATE oauth_auth_codes SET used = TRUE WHERE code = ?', [code]);
|
await db.execute('UPDATE oauth_auth_codes SET used = TRUE WHERE code = ?', [code]);
|
||||||
|
|
||||||
@@ -230,8 +272,10 @@ export function getAuthorizeHtml(params: {
|
|||||||
redirect_uri: string;
|
redirect_uri: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
|
code_challenge?: string;
|
||||||
|
code_challenge_method?: string;
|
||||||
}): string {
|
}): string {
|
||||||
const { client_id, redirect_uri, state, scope } = params;
|
const { client_id, redirect_uri, state, scope, code_challenge, code_challenge_method } = params;
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -261,6 +305,8 @@ export function getAuthorizeHtml(params: {
|
|||||||
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri)}">
|
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri)}">
|
||||||
${state ? `<input type="hidden" name="state" value="${escapeHtml(state)}">` : ''}
|
${state ? `<input type="hidden" name="state" value="${escapeHtml(state)}">` : ''}
|
||||||
${scope ? `<input type="hidden" name="scope" value="${escapeHtml(scope)}">` : ''}
|
${scope ? `<input type="hidden" name="scope" value="${escapeHtml(scope)}">` : ''}
|
||||||
|
${code_challenge ? `<input type="hidden" name="code_challenge" value="${escapeHtml(code_challenge)}">` : ''}
|
||||||
|
${code_challenge_method ? `<input type="hidden" name="code_challenge_method" value="${escapeHtml(code_challenge_method)}">` : ''}
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button type="submit" name="action" value="deny" class="deny">Deny</button>
|
<button type="submit" name="action" value="deny" class="deny">Deny</button>
|
||||||
<button type="submit" name="action" value="allow" class="allow">Allow</button>
|
<button type="submit" name="action" value="allow" class="allow">Allow</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user