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
|
||||
|
||||
A multi-account email MCP server for [Claude AI](https://claude.ai).
|
||||
Supports **Yahoo Mail** (IMAP App Password) and any **self-hosted mail server** (IMAP/SMTP).
|
||||
Hermes MCP is a hosted MCP gateway for messaging, knowledge, and social connectors.
|
||||
|
||||
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
|
||||
- Multi-account support: Yahoo Mail and custom IMAP/SMTP servers
|
||||
- Streamable HTTP transport (MCP 1.x) + legacy SSE endpoint
|
||||
- Automatic session recovery after server restarts
|
||||
- Docker and Kubernetes deployment ready
|
||||
Hermes is the integration and connector layer behind broader product work such as SquareMCP.
|
||||
|
||||
Use Hermes when you want:
|
||||
|
||||
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 |
|
||||
|------|-------------|------------|
|
||||
| `get_profile` | Get email address for an account | `account` |
|
||||
| `search_messages` | Search INBOX by keyword / sender / subject | `q`, `maxResults`, `account` |
|
||||
| `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"`).
|
||||
| Transport | URL |
|
||||
|----------|-----|
|
||||
| Streamable HTTP (preferred) | `https://hermes.squaremcp.com/mcp` |
|
||||
| Legacy SSE | `https://hermes.squaremcp.com/sse` |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
# 1. Install dependencies
|
||||
npm install
|
||||
|
||||
# 2. Configure credentials
|
||||
cp .env.example .env
|
||||
# Edit .env with your email credentials
|
||||
|
||||
# 3. Run in dev mode
|
||||
npm run dev
|
||||
|
||||
# 4. Verify
|
||||
curl http://localhost:3456/health
|
||||
# → {"status":"ok","service":"hermes-mcp"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
The local server runs on port `3456` by default.
|
||||
|
||||
---
|
||||
|
||||
## Connecting to Claude.ai
|
||||
## Deployment
|
||||
|
||||
1. Go to **Claude.ai → Settings → Connectors → Add custom connector**
|
||||
2. Enter your server URL: `https://your-domain.com/mcp`
|
||||
3. Click **Connect**
|
||||
Production deployment notes are in:
|
||||
|
||||
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
|
||||
- Building and pushing a Docker image to the local registry
|
||||
- Applying the Kubernetes Deployment / Service / Ingress manifests
|
||||
- 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
|
||||
1. the `hermes.squaremcp.com` domain
|
||||
2. Streamable HTTP `/mcp` as the default transport
|
||||
3. the dedicated client setup docs linked above
|
||||
|
||||
31
src/db.ts
31
src/db.ts
@@ -7,6 +7,30 @@ const password = process.env.MYSQL_PASSWORD || '';
|
||||
|
||||
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 {
|
||||
if (!pool) {
|
||||
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,
|
||||
client_id VARCHAR(255),
|
||||
redirect_uri TEXT,
|
||||
scope TEXT NULL,
|
||||
code_challenge TEXT NULL,
|
||||
code_challenge_method VARCHAR(20) NULL,
|
||||
expires_at TIMESTAMP,
|
||||
used BOOLEAN DEFAULT FALSE,
|
||||
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(`
|
||||
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
// 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 state = req.query.state 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;
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
@@ -254,7 +278,14 @@ app.get('/oauth/authorize', async (req, res) => {
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -262,6 +293,8 @@ app.post('/oauth/authorize', async (req, res) => {
|
||||
const redirectUri = req.body.redirect_uri as string | undefined;
|
||||
const state = req.body.state 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;
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
@@ -284,7 +317,7 @@ app.post('/oauth/authorize', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const code = await createAuthCode(clientId, redirectUri, scope);
|
||||
const code = await createAuthCode(clientId, redirectUri, scope, codeChallenge, codeChallengeMethod);
|
||||
const url = new URL(redirectUri);
|
||||
url.searchParams.set('code', code.code);
|
||||
if (state) url.searchParams.set('state', state);
|
||||
@@ -299,17 +332,19 @@ app.post('/oauth/token', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = req.body.client_id as string | undefined;
|
||||
const clientSecret = req.body.client_secret as string | undefined;
|
||||
const basicAuth = extractBasicClientCredentials(req);
|
||||
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 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' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await exchangeCodeForToken(clientId, clientSecret, code, redirectUri);
|
||||
const token = await exchangeCodeForToken(clientId, clientSecret, code, redirectUri, codeVerifier);
|
||||
if (!token) {
|
||||
res.status(400).json({ error: 'invalid_grant' });
|
||||
return;
|
||||
@@ -973,7 +1008,8 @@ const oauthDiscovery = {
|
||||
registration_endpoint: `${SERVER_URL}/oauth/register`,
|
||||
response_types_supported: ['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 = {
|
||||
|
||||
60
src/oauth.ts
60
src/oauth.ts
@@ -18,6 +18,8 @@ interface AuthCode {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
scope?: string;
|
||||
code_challenge?: string;
|
||||
code_challenge_method?: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
@@ -57,6 +59,19 @@ export function generateAccessToken(): string {
|
||||
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: {
|
||||
client_name?: string;
|
||||
redirect_uris?: string[];
|
||||
@@ -122,20 +137,24 @@ export async function getClient(clientId: string): Promise<Client | undefined> {
|
||||
export async function createAuthCode(
|
||||
clientId: string,
|
||||
redirectUri: string,
|
||||
scope?: string
|
||||
scope?: string,
|
||||
codeChallenge?: string,
|
||||
codeChallengeMethod?: string
|
||||
): Promise<AuthCode> {
|
||||
const code: AuthCode = {
|
||||
code: generateAuthCode(),
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: codeChallengeMethod,
|
||||
expires_at: Date.now() + AUTH_CODE_EXPIRY_MS,
|
||||
};
|
||||
|
||||
const pool = getPool();
|
||||
await pool.execute(
|
||||
'INSERT INTO oauth_auth_codes (code, client_id, redirect_uri, expires_at) VALUES (?, ?, ?, ?)',
|
||||
[code.code, clientId, redirectUri, new Date(code.expires_at)]
|
||||
'INSERT INTO oauth_auth_codes (code, client_id, redirect_uri, scope, code_challenge, code_challenge_method, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[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}`);
|
||||
@@ -144,9 +163,10 @@ export async function createAuthCode(
|
||||
|
||||
export async function exchangeCodeForToken(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
clientSecret: string | undefined,
|
||||
code: string,
|
||||
redirectUri: string
|
||||
redirectUri: string,
|
||||
codeVerifier?: string
|
||||
): Promise<Token | null> {
|
||||
let client: Client | undefined;
|
||||
try {
|
||||
@@ -156,7 +176,17 @@ export async function exchangeCodeForToken(
|
||||
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');
|
||||
return null;
|
||||
}
|
||||
@@ -180,6 +210,18 @@ export async function exchangeCodeForToken(
|
||||
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
|
||||
await db.execute('UPDATE oauth_auth_codes SET used = TRUE WHERE code = ?', [code]);
|
||||
|
||||
@@ -230,8 +272,10 @@ export function getAuthorizeHtml(params: {
|
||||
redirect_uri: string;
|
||||
state?: string;
|
||||
scope?: string;
|
||||
code_challenge?: string;
|
||||
code_challenge_method?: 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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -261,6 +305,8 @@ export function getAuthorizeHtml(params: {
|
||||
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri)}">
|
||||
${state ? `<input type="hidden" name="state" value="${escapeHtml(state)}">` : ''}
|
||||
${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">
|
||||
<button type="submit" name="action" value="deny" class="deny">Deny</button>
|
||||
<button type="submit" name="action" value="allow" class="allow">Allow</button>
|
||||
|
||||
Reference in New Issue
Block a user