Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b8ce54231 | |||
| 1cf6e3e4d3 | |||
| 708a4abba7 | |||
| 93d01bc39d | |||
| 8c145ae274 | |||
| a5a0616941 | |||
| 75136ee6bd | |||
| 332ee26de2 | |||
| 5b72ded2d5 | |||
| 4e55c1e4aa | |||
| 3900073086 | |||
| 7ac46e1d2e | |||
| 37d4f37206 | |||
| eb7a544a0c | |||
| 2272fac26f | |||
| 96e132c2fb | |||
| f85b6301dd | |||
| d096627b28 | |||
| 672ca10ffe | |||
| 417104605f | |||
| 0bcf13800e | |||
| dbc9328bf5 | |||
| ed23999c50 | |||
| 30c9e9e308 | |||
| 748d308672 | |||
| 3e33ee4d61 | |||
| 074d48f536 | |||
| eb11011017 | |||
| 04267593fb | |||
| 558bc9e034 | |||
| 72357ed9ee | |||
| 4728eaa578 | |||
| 7c4ef8a51c | |||
| 2eab4b92cc | |||
| 51502055db | |||
| 553d9647c2 | |||
| b954ce5f72 | |||
| b258ee0a07 | |||
| e6354aae00 | |||
| 78520b9069 | |||
| dfff0e913d | |||
| 21c030fee5 | |||
| 3716c42668 |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 286 KiB |
@@ -1,6 +1,5 @@
|
|||||||
# Core Server Configuration
|
APP_PORT=3000
|
||||||
PORT=3000
|
|
||||||
API_KEY=your_secret_api_key_here
|
API_KEY=your_secret_api_key_here
|
||||||
|
|
||||||
# Redis Configuration (Use redis://redis:6379 for Docker)
|
REDIS_HOST=redis
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_PORT=6379
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v1
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run linting
|
||||||
|
run: bun run check-all
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: bun run test
|
||||||
@@ -19,3 +19,6 @@ docs/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Worktrees
|
||||||
|
.worktrees/
|
||||||
|
|||||||
@@ -11,4 +11,7 @@ ENV NODE_ENV=production
|
|||||||
USER bun
|
USER bun
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["bun", "run", "src/index.ts"]
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||||
|
CMD bun -e "fetch('http://localhost:3000/health').then(res => res.ok ? process.exit(0) : process.exit(1)).catch(e => process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["bun", "run", "start"]
|
||||||
|
|||||||
@@ -1,66 +1,124 @@
|
|||||||
# toknd — Auth Broker
|
# toknd — The Minimal Token Broker
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
**toknd** is a minimal, centralized authentication and token broker. Built with **Bun**, **Hono**, and **Redis**, it serves as a middleware layer that manages OAuth2 providers, token persistence, and automatic refreshes, allowing your applications to focus on their core logic.
|
Building integrations is fun until you have to manage the tokens. Suddenly, you're writing cron jobs to handle refresh cycles, dealing with expired sessions in the middle of background tasks, and copying OAuth secrets manually across multiple microservices.
|
||||||
|
|
||||||
|
If you are building AI agents that need to take actions in the real world, the absolute last thing you want is your LLM trying to debug an OAuth redirect flow.
|
||||||
|
|
||||||
|
**toknd** is a minimal, centralized authentication and auth token broker and middleware with **native multi-tenancy**. Built with **Bun**, **Hono**, and **Redis**, it acts as a secure "wallet" that sits between your applications and the external APIs they need to access.
|
||||||
|
|
||||||
|
You just need to authenticate once. toknd manages the lifecycle of the tokens forever, securely isolated by tenant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why does this exist?
|
||||||
|
|
||||||
|
There are massive enterprise identity brokers (like Auth0 or Dex), and then there are reverse proxies (like oauth2-proxy). **toknd** is built to bridge the gap while remaining minimal and open-source. It provides the lightweight, developer-friendly infrastructure of a proxy, but with the specific "Token Vault" and **Multi-Tenancy** capabilities required for modern AI and microservice architectures—without the bloat, vendor lock-in, or SaaS costs.
|
||||||
|
|
||||||
|
## Use-Cases
|
||||||
|
- **Multi-Tenant SaaS Platforms:** If you are building a platform where *your* users need to connect their own GitHub or Google accounts, toknd handles the isolation. Use a unique `tenantId` for each of your customers to keep their credentials safe and separate.
|
||||||
|
- **The Ultimate AI Agent Wallet:** Equip your autonomous agents with a secure keychain. When your agent needs to hit a service backed by oauth, like GitHub or Notion API, it just asks toknd for a token. It gets a short-lived Bearer token instantly, keeping your permanent secrets completely isolated from dynamic AI environments.
|
||||||
|
- **Set It and Forget It:** toknd handles automated background refreshes. Your data ingestion pipelines, RAG syncs, and headless integration workers will never stall out due to a `401 Unauthorized` error again.
|
||||||
|
- **Microservice Centralization:** Stop implementing OAuth in every new service. Centralize your credentials so your microservices only need one internal API key to request valid access tokens for any configured provider.
|
||||||
|
- **Secure Secret Isolation:** OAuth client IDs, secrets, and long-lived refresh tokens stay locked inside toknd's Redis vault, drastically reducing your attack surface. All you need to do is secure the store.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Centralized management for multiple OAuth2 providers (Google, Trakt, GitHub, etc.).
|
- **Native Multi-Tenancy:** Isolate tokens for unlimited users/tenants using a single instance.
|
||||||
- Automatic token refreshes.
|
- **Drop-in Infrastructure:** Deploys in seconds via Docker (or Podman) Compose or just a simple Bun script.
|
||||||
- Secure and isolated API access via API key authentication.
|
- **Centralized Provider Management:** Native support for managing multiple OAuth2 providers (Google, GitHub, Trakt, etc.).
|
||||||
- Web-based dashboard for configuration management.
|
- **API Key Security:** Isolated and secure access to the broker via master API keys. Each instance can use its own key for isolation.
|
||||||
- Docker Compose support for simplified deployment.
|
- **Web Dashboard:** Built-in clean ad modern UI for managing provider configurations and viewing live token statuses.
|
||||||
- High performance and low-latency powered by Bun and Redis.
|
- **Blisteringly Fast:** Powered by Bun and Redis for ultra-low latency token retrieval.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Runtime**: [Bun](https://bun.sh/)
|
- **Runtime**: [Bun](https://bun.sh/)
|
||||||
- **Web Framework**: [Hono](https://hono.dev/)
|
- **Web Framework**: [Hono](https://hono.dev/)
|
||||||
- **Data Store**: Redis
|
- **Data Store**: [Redis](https://redis.io/)
|
||||||
- **Styling**: Tailwind CSS & DaisyUI
|
- **Styling**: [Tailwind CSS](https://tailwindcss.com/) & [DaisyUI](https://daisyui.com/)
|
||||||
- **Schema & Validation**: Zod
|
- **Schema & Validation**: [Zod](https://zod.dev/)
|
||||||
|
- **Docs**: [Scalar](https://github.com/scalar/scalar)
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### 1. Environment Setup
|
|
||||||
Clone the repository and create your environment file:
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
Ensure you define a strong `API_KEY` in your `.env`.
|
|
||||||
|
|
||||||
### 2. Local Development (with Auto-Watch)
|
|
||||||
We use a Docker Compose override system to enable hot-reloading locally:
|
|
||||||
```bash
|
|
||||||
podman compose up --build
|
|
||||||
```
|
|
||||||
*Note: This mounts your ./src directory into the container and uses bun --hot to restart on any code changes.*
|
|
||||||
|
|
||||||
### 3. Production Deployment
|
|
||||||
For production, only the core docker-compose.yml is used:
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
All protected endpoints require an Authorization header:
|
|
||||||
`Authorization: Bearer <your_master_api_key>`
|
|
||||||
|
|
||||||
### Token Brokerage
|
|
||||||
- **Get Valid Token**: `GET /api/token/:provider`
|
|
||||||
- Returns a valid access token. Automatically triggers a refresh if the current one is expired.
|
|
||||||
- **Registry Status**: `GET /api/status`
|
|
||||||
- Returns the connectivity and refresh status of all configured providers.
|
|
||||||
|
|
||||||
### Authentication Flow
|
|
||||||
1. **Initiate**: `GET /auth/:provider/login`
|
|
||||||
2. **Callback**: `GET /auth/callback` (Handled internally by toknd)
|
|
||||||
|
|
||||||
## Dashboard
|
|
||||||
Access the toknd dashboard at:
|
|
||||||
`http://localhost:3000/app`
|
|
||||||
|
|
||||||
Authenticate the registry using your Master API Key to manage your providers and view live token status.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
toknd is designed to be too easy to set-up. You can deploy it as a containerized service or host it directly on bare metal.
|
||||||
|
|
||||||
|
### 1. Environment Setup
|
||||||
|
Clone the repository and set up your environment:
|
||||||
|
```bash
|
||||||
|
git clone https://git.ramvignesh.dev/toknd_auth.git
|
||||||
|
cd toknd
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
Open `.env`, define your own strong `API_KEY`, and map the ports (optional)
|
||||||
|
|
||||||
|
### 2. Choose Deployment Method
|
||||||
|
|
||||||
|
#### Option A: Docker Compose (Recommended)
|
||||||
|
The easiest way to get up and running. This spins up both toknd and a dedicated Redis instance.
|
||||||
|
|
||||||
|
- **Production**:
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
(or)
|
||||||
|
podman compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
#### Option B: Bare Metal
|
||||||
|
If you already have Bun and Redis running in your environment.
|
||||||
|
|
||||||
|
1. **Install Dependencies**:
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
2. **Start the Server**:
|
||||||
|
- **Production**: `bun run start`
|
||||||
|
- **Development**: `bun run dev`
|
||||||
|
|
||||||
|
*Note: Make sure your Redis server is running and accessible via the `HOST` and `PORT` in your `.env`.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage & API Reference
|
||||||
|
|
||||||
|
toknd provides a built-in **Scalar API Reference** so you can explore and test endpoints right from your browser.
|
||||||
|
|
||||||
|
- **Interactive UI**: [http://localhost:3000/api](http://localhost:3000/api) (or `/docs`)
|
||||||
|
- **OpenAPI Spec**: [http://localhost:3000/doc](http://localhost:3000/doc)
|
||||||
|
|
||||||
|
### Multi-Tenancy & The Golden Rule
|
||||||
|
All protected endpoints require your master API key and a **Tenant ID** for isolation:
|
||||||
|
|
||||||
|
1. **Authentication:** Use your API key in the `Authorization` header.
|
||||||
|
2. **Isolation:** Pass your unique tenant identifier in the `X-Tenant-ID` header.
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <your_master_api_key>
|
||||||
|
X-Tenant-ID: <unique_user_or_org_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Starting an OAuth Flow
|
||||||
|
To connect a new account for a specific tenant, redirect the user to:
|
||||||
|
`http://localhost:3000/v1/auth/{provider}/login?tenantId={your_tenant_id}`
|
||||||
|
|
||||||
|
### The Dashboard
|
||||||
|
REST API too complicated to use? No problem!
|
||||||
|
You absolutely don't have to manage everything via curl. Access the web dashboard to configure providers, trigger manual refreshes, and monitor token health:
|
||||||
|
**`http://localhost:3000/app`**
|
||||||
|
|
||||||
|
*(Authenticate using your Master API Key).*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
This is an open-source passion project built to solve a real headache in modern application architecture. Pull requests, issues, and feature requests (especially for new built-in OAuth providers!) are highly encouraged.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
@@ -61,5 +61,17 @@
|
|||||||
"organizeImports": "on"
|
"organizeImports": "on"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"includes": ["tests/**/*"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.15",
|
"@biomejs/biome": "2.4.15",
|
||||||
"@types/node": "^22.19.18",
|
"@types/node": "^22.19.18",
|
||||||
|
"bun-types": "^1.3.13",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^17.0.4",
|
"lint-staged": "^17.0.4",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
@@ -66,6 +67,8 @@
|
|||||||
|
|
||||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||||
|
|
||||||
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||||
|
|
||||||
"cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
|
"cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[test]
|
||||||
|
preload = ["./tests/setup.ts"]
|
||||||
@@ -3,16 +3,20 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-3000}:3000"
|
- "${APP_PORT:-3000}:3000"
|
||||||
environment:
|
environment:
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
- API_KEY=${API_KEY}
|
- API_KEY=${API_KEY}
|
||||||
|
- APP_PORT=3000
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:alpine
|
image: redis:alpine
|
||||||
restart: always
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --hot src/index.ts",
|
"dev": "bun run --hot src/index.ts",
|
||||||
|
"start": "bun src/index.ts",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"lint": "bunx @biomejs/biome check src",
|
"lint": "bunx @biomejs/biome check src",
|
||||||
|
"check-all": "bunx @biomejs/biome check .",
|
||||||
"format": "bunx @biomejs/biome format --write src",
|
"format": "bunx @biomejs/biome format --write src",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.15",
|
"@biomejs/biome": "2.4.15",
|
||||||
"@types/node": "^22.19.18",
|
"@types/node": "^22.19.18",
|
||||||
|
"bun-types": "^1.3.13",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^17.0.4",
|
"lint-staged": "^17.0.4",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 745 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "toknd_auth_broker",
|
||||||
|
"short_name": "toknd",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#bdff00",
|
||||||
|
"background_color": "#000",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const configSchema = z.object({
|
const configSchema = z.object({
|
||||||
PORT: z.string().default("3000"),
|
APP_PORT: z.string().default("3000"),
|
||||||
REDIS_URL: z.string(),
|
REDIS_HOST: z.string().default("redis"),
|
||||||
|
REDIS_PORT: z.coerce.number().default(6379),
|
||||||
API_KEY: z.string(),
|
API_KEY: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { API_VERSION, APP_VERSION } from "./version";
|
||||||
|
|
||||||
|
export { API_VERSION, APP_VERSION };
|
||||||
|
export const API_PREFIX = `/api/${API_VERSION}`;
|
||||||
|
export const AUTH_PREFIX = `/${API_VERSION}/auth`;
|
||||||
|
export const DOCS_PREFIX = `/docs/${API_VERSION}`;
|
||||||
@@ -40,4 +40,13 @@ export class ConfigManager {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteProviderConfig(provider: string): Promise<void> {
|
||||||
|
await this.redis.del(`config:${provider}`);
|
||||||
|
// Also clean up tokens across all tenants
|
||||||
|
const tokenKeys = await this.redis.keys(`tenant:*:provider:${provider}:*`);
|
||||||
|
if (tokenKeys.length > 0) {
|
||||||
|
await this.redis.del(...tokenKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { Redis } from "ioredis";
|
import { Redis } from "ioredis";
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
|
|
||||||
export const redis = new Redis(config.REDIS_URL);
|
export const redis = new Redis({
|
||||||
|
host: config.REDIS_HOST,
|
||||||
|
port: config.REDIS_PORT,
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,38 +7,38 @@ export class TokenManager {
|
|||||||
private provider: OAuthProvider,
|
private provider: OAuthProvider,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getAccessToken(providerName: string): Promise<string | null> {
|
async getAccessToken(tenantId: string, providerName: string): Promise<string | null> {
|
||||||
const accessKey = `provider:${providerName}:access_token`;
|
const accessKey = `tenant:${tenantId}:provider:${providerName}:access_token`;
|
||||||
const cached = await this.redis.get(accessKey);
|
const cached = await this.redis.get(accessKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const refreshKey = `provider:${providerName}:refresh_token`;
|
const refreshKey = `tenant:${tenantId}:provider:${providerName}:refresh_token`;
|
||||||
const refreshToken = await this.redis.get(refreshKey);
|
const refreshToken = await this.redis.get(refreshKey);
|
||||||
if (!refreshToken) return null;
|
if (!refreshToken) return null;
|
||||||
|
|
||||||
const tokens = await this.provider.refreshToken(refreshToken);
|
const tokens = await this.provider.refreshToken(refreshToken);
|
||||||
await this.saveTokens(providerName, tokens);
|
await this.saveTokens(tenantId, providerName, tokens);
|
||||||
return tokens.accessToken;
|
return tokens.accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshAccessToken(providerName: string): Promise<string | null> {
|
async refreshAccessToken(tenantId: string, providerName: string): Promise<string | null> {
|
||||||
const refreshKey = `provider:${providerName}:refresh_token`;
|
const refreshKey = `tenant:${tenantId}:provider:${providerName}:refresh_token`;
|
||||||
const refreshToken = await this.redis.get(refreshKey);
|
const refreshToken = await this.redis.get(refreshKey);
|
||||||
if (!refreshToken) return null;
|
if (!refreshToken) return null;
|
||||||
|
|
||||||
const tokens = await this.provider.refreshToken(refreshToken);
|
const tokens = await this.provider.refreshToken(refreshToken);
|
||||||
await this.saveTokens(providerName, tokens);
|
await this.saveTokens(tenantId, providerName, tokens);
|
||||||
return tokens.accessToken;
|
return tokens.accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveTokens(providerName: string, tokens: TokenResponse) {
|
async saveTokens(tenantId: string, providerName: string, tokens: TokenResponse) {
|
||||||
|
const baseKey = `tenant:${tenantId}:provider:${providerName}`;
|
||||||
|
await this.redis.set(`${baseKey}:access_token`, tokens.accessToken, "EX", tokens.expiresIn);
|
||||||
|
await this.redis.set(`${baseKey}:refresh_token`, tokens.refreshToken);
|
||||||
|
await this.redis.set(`${baseKey}:last_updated`, new Date().toISOString());
|
||||||
await this.redis.set(
|
await this.redis.set(
|
||||||
`provider:${providerName}:access_token`,
|
`${baseKey}:expires_at`,
|
||||||
tokens.accessToken,
|
new Date(Date.now() + tokens.expiresIn * 1000).toISOString(),
|
||||||
"EX",
|
|
||||||
tokens.expiresIn,
|
|
||||||
);
|
);
|
||||||
await this.redis.set(`provider:${providerName}:refresh_token`, tokens.refreshToken);
|
|
||||||
await this.redis.set(`provider:${providerName}:last_updated`, new Date().toISOString());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||||
import { Scalar } from "@scalar/hono-api-reference";
|
import { Scalar } from "@scalar/hono-api-reference";
|
||||||
|
import { serveStatic } from "hono/bun";
|
||||||
import { logger } from "hono/logger";
|
import { logger } from "hono/logger";
|
||||||
import { prettyJSON } from "hono/pretty-json";
|
import { prettyJSON } from "hono/pretty-json";
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
|
import { API_PREFIX, AUTH_PREFIX, DOCS_PREFIX } from "./constants";
|
||||||
|
import { redis } from "./core/RedisClient";
|
||||||
|
import { openApiSpec, securityScheme, tenantIdScheme } from "./openapi";
|
||||||
import { apiRoutes } from "./routes/api";
|
import { apiRoutes } from "./routes/api";
|
||||||
import { authRoutes } from "./routes/auth";
|
import { authRoutes } from "./routes/auth";
|
||||||
import { configRoutes } from "./routes/config";
|
import { configRoutes } from "./routes/config";
|
||||||
@@ -11,38 +15,32 @@ import { dashboardRoutes } from "./routes/dashboard";
|
|||||||
const app = new OpenAPIHono({ strict: false });
|
const app = new OpenAPIHono({ strict: false });
|
||||||
|
|
||||||
// OpenAPI specs
|
// OpenAPI specs
|
||||||
app.doc("/doc", {
|
app.doc(`${DOCS_PREFIX}/openapi.json`, openApiSpec);
|
||||||
openapi: "3.0.0",
|
app.openAPIRegistry.registerComponent("securitySchemes", "API_KEY", securityScheme);
|
||||||
info: {
|
app.openAPIRegistry.registerComponent("securitySchemes", "TENANT_ID", tenantIdScheme);
|
||||||
version: "1.0.0",
|
|
||||||
title: "toknd — Auth Broker API",
|
|
||||||
description: "Centralized token management and OAuth2 broker service.",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
app.openAPIRegistry.registerComponent("securitySchemes", "API_KEY", {
|
|
||||||
type: "http",
|
|
||||||
scheme: "bearer",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scalar API Reference
|
// Scalar API Reference
|
||||||
app.get(
|
app.get(
|
||||||
"/api",
|
DOCS_PREFIX,
|
||||||
Scalar({
|
Scalar({
|
||||||
theme: "solarized",
|
theme: "solarized",
|
||||||
url: "/doc",
|
url: `${DOCS_PREFIX}/openapi.json`,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.get("/docs", (c) => c.redirect("/api"));
|
app.get("/docs", (c) => c.redirect(DOCS_PREFIX));
|
||||||
|
app.get("/api", (c) => c.redirect(DOCS_PREFIX));
|
||||||
|
|
||||||
app.use("*", logger());
|
app.use("*", logger());
|
||||||
app.use("*", prettyJSON());
|
app.use("*", prettyJSON());
|
||||||
|
|
||||||
app.get("/", (c) => c.redirect("/app"));
|
app.get("/", (c) => c.redirect("/app"));
|
||||||
|
|
||||||
app.route("/auth", authRoutes);
|
app.get("/favicon.ico", serveStatic({ path: "./public/favicon.ico" }));
|
||||||
app.route("/api/config", configRoutes);
|
app.use("/static/*", serveStatic({ root: "./public" }));
|
||||||
app.route("/api", apiRoutes);
|
app.get("/app/dashboard.js", serveStatic({ path: "./src/views/dashboard.js" }));
|
||||||
|
app.route(AUTH_PREFIX, authRoutes);
|
||||||
|
app.route(`${API_PREFIX}/config`, configRoutes);
|
||||||
|
app.route(API_PREFIX, apiRoutes);
|
||||||
app.route("/app", dashboardRoutes);
|
app.route("/app", dashboardRoutes);
|
||||||
|
|
||||||
app.notFound((c) => {
|
app.notFound((c) => {
|
||||||
@@ -63,9 +61,16 @@ app.onError((err, c) => {
|
|||||||
return c.json({ error: "Internal Server Error", message: err.message }, 500);
|
return c.json({ error: "Internal Server Error", message: err.message }, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
app.get("/health", async (c) => {
|
||||||
|
if (redis.status !== "ready") {
|
||||||
|
return c.json({ status: "error", message: "Redis down", redis: redis.status }, 503);
|
||||||
|
}
|
||||||
|
return c.json({ status: "ok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
export { app };
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
port: Number.parseInt(config.PORT, 10),
|
port: Number.parseInt(config.APP_PORT, 10),
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import type { Context, Next } from "hono";
|
import type { Context, Next } from "hono";
|
||||||
|
import { getCookie } from "hono/cookie";
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
|
|
||||||
export const authMiddleware = async (c: Context, next: Next) => {
|
export const authMiddleware = async (c: Context, next: Next) => {
|
||||||
const authHeader = c.req.header("Authorization");
|
const authHeader = c.req.header("Authorization");
|
||||||
|
const cookieToken = getCookie(c, "toknd_api_key");
|
||||||
|
|
||||||
if (!authHeader?.startsWith("Bearer ")) {
|
let token: string | undefined;
|
||||||
return c.json({ error: "Missing or invalid authorization header" }, 401);
|
|
||||||
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
|
token = authHeader.split(" ")[1];
|
||||||
|
} else if (cookieToken) {
|
||||||
|
token = cookieToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = authHeader.split(" ")[1];
|
if (!token) {
|
||||||
|
return c.json({ error: "Missing or invalid authorization" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
if (token !== config.API_KEY) {
|
if (token !== config.API_KEY) {
|
||||||
return c.json({ error: "Invalid API key" }, 403);
|
return c.json({ error: "Invalid API key" }, 403);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { API_VERSION, APP_VERSION } from "./constants";
|
||||||
|
|
||||||
|
export const openApiSpec = {
|
||||||
|
openapi: "3.0.0",
|
||||||
|
info: {
|
||||||
|
version: `${API_VERSION}.${APP_VERSION}`,
|
||||||
|
title: "toknd Auth Broker API",
|
||||||
|
description:
|
||||||
|
"A high-performance OAuth2 broker and token management service with multi-tenancy support. Designed to centralize provider configurations and automate token lifecycle management across distributed systems, securely isolated by Tenant IDs.",
|
||||||
|
},
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
name: "Tokens",
|
||||||
|
description: "Endpoint operations for accessing and force-refreshing active provider tokens.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Management",
|
||||||
|
description: "Administrative operations for provider lifecycle and configuration.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Auth (Internal)",
|
||||||
|
description: "System-level OAuth2 handshake and callback processing.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
security: [{ API_KEY: [], TENANT_ID: [] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const securityScheme = {
|
||||||
|
type: "http",
|
||||||
|
scheme: "bearer",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const tenantIdScheme = {
|
||||||
|
type: "apiKey",
|
||||||
|
in: "header",
|
||||||
|
name: "X-Tenant-ID",
|
||||||
|
description: "The unique identifier for the tenant (user or organization).",
|
||||||
|
} as const;
|
||||||
@@ -5,7 +5,7 @@ import type { OAuthProvider, TokenResponse } from "./interface";
|
|||||||
const TokenResponseSchema = z.object({
|
const TokenResponseSchema = z.object({
|
||||||
access_token: z.string(),
|
access_token: z.string(),
|
||||||
refresh_token: z.string().optional(),
|
refresh_token: z.string().optional(),
|
||||||
expires_in: z.number(),
|
expires_in: z.coerce.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export class GenericProvider implements OAuthProvider {
|
export class GenericProvider implements OAuthProvider {
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const StatusResponseSchema = z
|
|||||||
accessToken: z.string().nullable(),
|
accessToken: z.string().nullable(),
|
||||||
refreshToken: z.string().nullable(),
|
refreshToken: z.string().nullable(),
|
||||||
lastUpdated: z.string().nullable(),
|
lastUpdated: z.string().nullable(),
|
||||||
|
expiresAt: z.string().nullable().openapi({
|
||||||
|
example: "2026-05-12T10:00:00.000Z",
|
||||||
|
description: "ISO timestamp of when the access token expires",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.openapi("StatusResponse");
|
.openapi("StatusResponse");
|
||||||
@@ -32,6 +36,10 @@ const RefreshResponseSchema = z
|
|||||||
accessToken: z.string().nullable(),
|
accessToken: z.string().nullable(),
|
||||||
refreshToken: z.string().nullable(),
|
refreshToken: z.string().nullable(),
|
||||||
lastUpdated: z.string().nullable(),
|
lastUpdated: z.string().nullable(),
|
||||||
|
expiresAt: z.string().nullable().openapi({
|
||||||
|
example: "2026-05-12T10:00:00.000Z",
|
||||||
|
description: "ISO timestamp of when the access token expires",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.openapi("RefreshResponse");
|
.openapi("RefreshResponse");
|
||||||
@@ -46,7 +54,13 @@ const ErrorSchema = z
|
|||||||
const statusRoute = createRoute({
|
const statusRoute = createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/status",
|
path: "/status",
|
||||||
security: [{ API_KEY: [] }],
|
security: [{ API_KEY: [], TENANT_ID: [] }],
|
||||||
|
tags: ["Tokens"],
|
||||||
|
request: {
|
||||||
|
headers: z.object({
|
||||||
|
"x-tenant-id": z.string().openapi({ example: "my-tenant" }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
content: { "application/json": { schema: StatusResponseSchema } },
|
content: { "application/json": { schema: StatusResponseSchema } },
|
||||||
@@ -58,11 +72,15 @@ const statusRoute = createRoute({
|
|||||||
const tokenRoute = createRoute({
|
const tokenRoute = createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/token/{provider}",
|
path: "/token/{provider}",
|
||||||
security: [{ API_KEY: [] }],
|
security: [{ API_KEY: [], TENANT_ID: [] }],
|
||||||
|
tags: ["Tokens"],
|
||||||
request: {
|
request: {
|
||||||
params: z.object({
|
params: z.object({
|
||||||
provider: z.string().openapi({ example: "trakt" }),
|
provider: z.string().openapi({ example: "trakt" }),
|
||||||
}),
|
}),
|
||||||
|
headers: z.object({
|
||||||
|
"x-tenant-id": z.string().openapi({ example: "my-tenant" }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
@@ -79,11 +97,15 @@ const tokenRoute = createRoute({
|
|||||||
const refreshRoute = createRoute({
|
const refreshRoute = createRoute({
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/refresh/{provider}",
|
path: "/refresh/{provider}",
|
||||||
security: [{ API_KEY: [] }],
|
security: [{ API_KEY: [], TENANT_ID: [] }],
|
||||||
|
tags: ["Tokens"],
|
||||||
request: {
|
request: {
|
||||||
params: z.object({
|
params: z.object({
|
||||||
provider: z.string().openapi({ example: "trakt" }),
|
provider: z.string().openapi({ example: "trakt" }),
|
||||||
}),
|
}),
|
||||||
|
headers: z.object({
|
||||||
|
"x-tenant-id": z.string().openapi({ example: "my-tenant" }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
@@ -101,15 +123,18 @@ const refreshRoute = createRoute({
|
|||||||
apiRoutes.use("*", authMiddleware);
|
apiRoutes.use("*", authMiddleware);
|
||||||
|
|
||||||
apiRoutes.openapi(statusRoute, async (c) => {
|
apiRoutes.openapi(statusRoute, async (c) => {
|
||||||
|
const tenantId = c.req.valid("header")["x-tenant-id"];
|
||||||
const configManager = new ConfigManager(redis);
|
const configManager = new ConfigManager(redis);
|
||||||
const providers = await configManager.getAllProviders();
|
const providers = await configManager.getAllProviders();
|
||||||
const status: z.infer<typeof StatusResponseSchema> = {};
|
const status: z.infer<typeof StatusResponseSchema> = {};
|
||||||
|
|
||||||
for (const provider of Object.keys(providers)) {
|
for (const provider of Object.keys(providers)) {
|
||||||
const accessToken = await redis.get(`provider:${provider}:access_token`);
|
const baseKey = `tenant:${tenantId}:provider:${provider}`;
|
||||||
const refreshToken = await redis.get(`provider:${provider}:refresh_token`);
|
const accessToken = await redis.get(`${baseKey}:access_token`);
|
||||||
const lastUpdated = await redis.get(`provider:${provider}:last_updated`);
|
const refreshToken = await redis.get(`${baseKey}:refresh_token`);
|
||||||
status[provider] = { accessToken, refreshToken, lastUpdated };
|
const lastUpdated = await redis.get(`${baseKey}:last_updated`);
|
||||||
|
const expiresAt = await redis.get(`${baseKey}:expires_at`);
|
||||||
|
status[provider] = { accessToken, refreshToken, lastUpdated, expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json(status, 200);
|
return c.json(status, 200);
|
||||||
@@ -117,6 +142,7 @@ apiRoutes.openapi(statusRoute, async (c) => {
|
|||||||
|
|
||||||
apiRoutes.openapi(tokenRoute, async (c) => {
|
apiRoutes.openapi(tokenRoute, async (c) => {
|
||||||
const providerName = c.req.valid("param").provider;
|
const providerName = c.req.valid("param").provider;
|
||||||
|
const tenantId = c.req.valid("header")["x-tenant-id"];
|
||||||
const configManager = new ConfigManager(redis);
|
const configManager = new ConfigManager(redis);
|
||||||
const providerConfig = await configManager.getProviderConfig(providerName);
|
const providerConfig = await configManager.getProviderConfig(providerName);
|
||||||
|
|
||||||
@@ -127,7 +153,7 @@ apiRoutes.openapi(tokenRoute, async (c) => {
|
|||||||
const provider = new GenericProvider(providerName, providerConfig);
|
const provider = new GenericProvider(providerName, providerConfig);
|
||||||
const tokenManager = new TokenManager(redis, provider);
|
const tokenManager = new TokenManager(redis, provider);
|
||||||
|
|
||||||
const accessToken = await tokenManager.getAccessToken(providerName);
|
const accessToken = await tokenManager.getAccessToken(tenantId, providerName);
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
return c.json({ error: "No tokens found for provider" }, 404);
|
return c.json({ error: "No tokens found for provider" }, 404);
|
||||||
}
|
}
|
||||||
@@ -136,6 +162,7 @@ apiRoutes.openapi(tokenRoute, async (c) => {
|
|||||||
|
|
||||||
apiRoutes.openapi(refreshRoute, async (c) => {
|
apiRoutes.openapi(refreshRoute, async (c) => {
|
||||||
const providerName = c.req.valid("param").provider;
|
const providerName = c.req.valid("param").provider;
|
||||||
|
const tenantId = c.req.valid("header")["x-tenant-id"];
|
||||||
const configManager = new ConfigManager(redis);
|
const configManager = new ConfigManager(redis);
|
||||||
const providerConfig = await configManager.getProviderConfig(providerName);
|
const providerConfig = await configManager.getProviderConfig(providerName);
|
||||||
|
|
||||||
@@ -146,15 +173,17 @@ apiRoutes.openapi(refreshRoute, async (c) => {
|
|||||||
const provider = new GenericProvider(providerName, providerConfig);
|
const provider = new GenericProvider(providerName, providerConfig);
|
||||||
const tokenManager = new TokenManager(redis, provider);
|
const tokenManager = new TokenManager(redis, provider);
|
||||||
|
|
||||||
await tokenManager.refreshAccessToken(providerName);
|
await tokenManager.refreshAccessToken(tenantId, providerName);
|
||||||
const accessToken = await redis.get(`provider:${providerName}:access_token`);
|
const baseKey = `tenant:${tenantId}:provider:${providerName}`;
|
||||||
const refreshToken = await redis.get(`provider:${providerName}:refresh_token`);
|
const accessToken = await redis.get(`${baseKey}:access_token`);
|
||||||
const lastUpdated = await redis.get(`provider:${providerName}:last_updated`);
|
const refreshToken = await redis.get(`${baseKey}:refresh_token`);
|
||||||
|
const lastUpdated = await redis.get(`${baseKey}:last_updated`);
|
||||||
|
const expiresAt = await redis.get(`${baseKey}:expires_at`);
|
||||||
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
status: { accessToken, refreshToken, lastUpdated },
|
status: { accessToken, refreshToken, lastUpdated, expiresAt },
|
||||||
},
|
},
|
||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
||||||
import { ConfigManager } from "../core/ConfigManager";
|
import { ConfigManager } from "../core/ConfigManager";
|
||||||
import { redis } from "../core/RedisClient";
|
import { redis } from "../core/RedisClient";
|
||||||
@@ -19,10 +17,15 @@ const AuthErrorResponse = z
|
|||||||
const loginRoute = createRoute({
|
const loginRoute = createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/{provider}/login",
|
path: "/{provider}/login",
|
||||||
|
tags: ["Auth (Internal)"],
|
||||||
|
summary: "Start OAuth2 flow (Managed by System)",
|
||||||
request: {
|
request: {
|
||||||
params: z.object({
|
params: z.object({
|
||||||
provider: z.string().openapi({ example: "trakt" }),
|
provider: z.string().openapi({ example: "trakt" }),
|
||||||
}),
|
}),
|
||||||
|
query: z.object({
|
||||||
|
tenantId: z.string().openapi({ example: "my-tenant" }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
302: {
|
302: {
|
||||||
@@ -38,18 +41,17 @@ const loginRoute = createRoute({
|
|||||||
const callbackRoute = createRoute({
|
const callbackRoute = createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/callback",
|
path: "/callback",
|
||||||
|
tags: ["Auth (Internal)"],
|
||||||
|
summary: "OAuth2 callback handler (Managed by System)",
|
||||||
request: {
|
request: {
|
||||||
query: z.object({
|
query: z.object({
|
||||||
state: z
|
state: z.string().openapi({ description: "Composite state: tenantId:providerName" }),
|
||||||
.string()
|
|
||||||
.openapi({ description: "The provider name (passed as state during login)" }),
|
|
||||||
code: z.string().openapi({ description: "The authorization code from the provider" }),
|
code: z.string().openapi({ description: "The authorization code from the provider" }),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
302: {
|
||||||
description: "Success page indicating successful token exchange",
|
description: "Redirect to success page",
|
||||||
content: { "text/html": { schema: { type: "string" } } },
|
|
||||||
},
|
},
|
||||||
400: {
|
400: {
|
||||||
content: { "application/json": { schema: AuthErrorResponse } },
|
content: { "application/json": { schema: AuthErrorResponse } },
|
||||||
@@ -69,6 +71,7 @@ const callbackRoute = createRoute({
|
|||||||
// Implementations
|
// Implementations
|
||||||
authRoutes.openapi(loginRoute, async (c) => {
|
authRoutes.openapi(loginRoute, async (c) => {
|
||||||
const providerName = c.req.valid("param").provider;
|
const providerName = c.req.valid("param").provider;
|
||||||
|
const tenantId = c.req.valid("query").tenantId;
|
||||||
const configManager = new ConfigManager(redis);
|
const configManager = new ConfigManager(redis);
|
||||||
const providerConfig = await configManager.getProviderConfig(providerName);
|
const providerConfig = await configManager.getProviderConfig(providerName);
|
||||||
|
|
||||||
@@ -86,11 +89,27 @@ authRoutes.openapi(loginRoute, async (c) => {
|
|||||||
const url = new URL(c.req.url);
|
const url = new URL(c.req.url);
|
||||||
const redirectUri = providerConfig.redirectUri || `${url.origin}/auth/callback`;
|
const redirectUri = providerConfig.redirectUri || `${url.origin}/auth/callback`;
|
||||||
|
|
||||||
return c.redirect(provider.getAuthUrl(providerName, redirectUri));
|
// Pass both tenantId and providerName in state
|
||||||
|
const state = `${tenantId}:${providerName}`;
|
||||||
|
return c.redirect(provider.getAuthUrl(state, redirectUri));
|
||||||
});
|
});
|
||||||
|
|
||||||
authRoutes.openapi(callbackRoute, async (c) => {
|
authRoutes.openapi(callbackRoute, async (c) => {
|
||||||
const { state: providerName, code } = c.req.valid("query");
|
const { state, code } = c.req.valid("query");
|
||||||
|
|
||||||
|
// state is expected to be "tenantId:providerName"
|
||||||
|
const parts = state.split(":");
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: "Invalid State",
|
||||||
|
message: "The state parameter is invalid.",
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [tenantId, providerName] = parts;
|
||||||
|
|
||||||
const configManager = new ConfigManager(redis);
|
const configManager = new ConfigManager(redis);
|
||||||
const providerConfig = await configManager.getProviderConfig(providerName);
|
const providerConfig = await configManager.getProviderConfig(providerName);
|
||||||
@@ -113,12 +132,9 @@ authRoutes.openapi(callbackRoute, async (c) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const tokens = await provider.exchangeCode(code, redirectUri);
|
const tokens = await provider.exchangeCode(code, redirectUri);
|
||||||
await tokenManager.saveTokens(providerName, tokens);
|
await tokenManager.saveTokens(tenantId, providerName, tokens);
|
||||||
|
|
||||||
const htmlPath = join(process.cwd(), "src/views/success.html");
|
return c.redirect(`/app/success?provider=${providerName}&tenantId=${tenantId}`);
|
||||||
let html = await readFile(htmlPath, "utf-8");
|
|
||||||
html = html.replaceAll("__PROVIDER_NAME__", providerName);
|
|
||||||
return c.html(html);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred.";
|
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred.";
|
||||||
console.error(`[OAuth Error] ${errorMessage}`);
|
console.error(`[OAuth Error] ${errorMessage}`);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const listConfigRoute = createRoute({
|
|||||||
method: "get",
|
method: "get",
|
||||||
path: "/",
|
path: "/",
|
||||||
security: [{ API_KEY: [] }],
|
security: [{ API_KEY: [] }],
|
||||||
|
tags: ["Management"],
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
content: { "application/json": { schema: AllProvidersResponse } },
|
content: { "application/json": { schema: AllProvidersResponse } },
|
||||||
@@ -50,6 +51,7 @@ const setConfigRoute = createRoute({
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/{provider}",
|
path: "/{provider}",
|
||||||
security: [{ API_KEY: [] }],
|
security: [{ API_KEY: [] }],
|
||||||
|
tags: ["Management"],
|
||||||
request: {
|
request: {
|
||||||
params: z.object({
|
params: z.object({
|
||||||
provider: z.string().openapi({ example: "trakt" }),
|
provider: z.string().openapi({ example: "trakt" }),
|
||||||
@@ -70,6 +72,24 @@ const setConfigRoute = createRoute({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteConfigRoute = createRoute({
|
||||||
|
method: "delete",
|
||||||
|
path: "/{provider}",
|
||||||
|
security: [{ API_KEY: [] }],
|
||||||
|
tags: ["Management"],
|
||||||
|
request: {
|
||||||
|
params: z.object({
|
||||||
|
provider: z.string().openapi({ example: "trakt" }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: { "application/json": { schema: SuccessMessage } },
|
||||||
|
description: "Delete a provider configuration and its tokens",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Implementations
|
// Implementations
|
||||||
configRoutes.use("*", authMiddleware);
|
configRoutes.use("*", authMiddleware);
|
||||||
|
|
||||||
@@ -88,4 +108,12 @@ configRoutes.openapi(setConfigRoute, async (c) => {
|
|||||||
return c.json({ message: `Config for ${provider} saved successfully` }, 200);
|
return c.json({ message: `Config for ${provider} saved successfully` }, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
configRoutes.openapi(deleteConfigRoute, async (c) => {
|
||||||
|
const provider = c.req.valid("param").provider;
|
||||||
|
const configManager = new ConfigManager(redis);
|
||||||
|
|
||||||
|
await configManager.deleteProviderConfig(provider);
|
||||||
|
return c.json({ message: `Config for ${provider} deleted successfully` }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
export { configRoutes };
|
export { configRoutes };
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
|
|
||||||
const dashboardRoutes = new Hono({ strict: false });
|
|
||||||
|
|
||||||
dashboardRoutes.get("/", async (c) => {
|
|
||||||
try {
|
|
||||||
const htmlPath = join(process.cwd(), "src/views/dashboard.html");
|
|
||||||
const html = await readFile(htmlPath, "utf-8");
|
|
||||||
return c.html(html);
|
|
||||||
} catch (_error) {
|
|
||||||
return c.text("Error loading dashboard", 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export { dashboardRoutes };
|
|
||||||
@@ -0,0 +1,698 @@
|
|||||||
|
/** @jsxImportSource hono/jsx */
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||||||
|
import { html } from "hono/html";
|
||||||
|
import type { Child } from "hono/jsx";
|
||||||
|
import { config } from "../config";
|
||||||
|
import { API_PREFIX, API_VERSION, APP_VERSION, AUTH_PREFIX, DOCS_PREFIX } from "../constants";
|
||||||
|
|
||||||
|
const dashboardRoutes = new Hono({ strict: false });
|
||||||
|
|
||||||
|
export const Layout = (props: { title: string; children: Child; isUnlocked?: boolean }) => (
|
||||||
|
<>
|
||||||
|
{html`<!DOCTYPE html>`}
|
||||||
|
<html lang="en" data-theme="abyss">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{props.title}</title>
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.ico" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
<link
|
||||||
|
href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
type="text/css"
|
||||||
|
/>
|
||||||
|
<script src="https://unpkg.com/@phosphor-icons/web@2.1.1"></script>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.font-mono {
|
||||||
|
font-family: "DM Mono", monospace;
|
||||||
|
}
|
||||||
|
.text-xxs {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
class="bg-base-200/50 min-h-screen font-['DM_Sans',sans-serif] antialiased text-base-content tracking-tight"
|
||||||
|
x-data={`dashboard({
|
||||||
|
initialIsUnlocked: ${props.isUnlocked || false},
|
||||||
|
apiVersion: '${API_VERSION}',
|
||||||
|
appVersion: '${APP_VERSION}',
|
||||||
|
apiPrefix: '${API_PREFIX}',
|
||||||
|
authPrefix: '${AUTH_PREFIX}',
|
||||||
|
docsPrefix: '${DOCS_PREFIX}'
|
||||||
|
})`}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
<script src="/app/dashboard.js"></script>
|
||||||
|
<script src="//unpkg.com/alpinejs@3" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Dashboard = (props: { isUnlocked: boolean }) => (
|
||||||
|
<Layout title="toknd — Auth Broker Dashboard" isUnlocked={props.isUnlocked}>
|
||||||
|
<div class="flex flex-col min-h-screen">
|
||||||
|
<div class="navbar bg-base-100 shadow-sm px-4 md:px-8 border-b border-base-300 relative z-50">
|
||||||
|
<div class="flex-1 flex items-center gap-6">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-primary-content font-semibold text-lg">
|
||||||
|
<i class="ph-duotone ph-fingerprint"></i>
|
||||||
|
</div>
|
||||||
|
<div class="text-xl font-semibold tracking-tight">
|
||||||
|
toknd <span class="text-xs font-normal opacity-50 ml-1">auth broker</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-none lg:hidden">
|
||||||
|
<label for="mobile-menu" class="btn btn-square btn-ghost">
|
||||||
|
<i class="ph-duotone ph-chart-donut text-3xl"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="checkbox" id="mobile-menu" class="peer hidden" />
|
||||||
|
|
||||||
|
<div class="fixed inset-y-0 right-0 w-80 bg-base-100 border-l border-base-300 shadow-2xl p-6 flex flex-col gap-6 transform translate-x-full peer-checked:translate-x-0 transition-transform z-50 lg:static lg:flex-none lg:w-auto lg:bg-transparent lg:border-none lg:shadow-none lg:p-0 lg:flex-row lg:items-center lg:gap-4 lg:translate-x-0 lg:z-auto">
|
||||||
|
<div class="flex justify-end lg:hidden">
|
||||||
|
<label for="mobile-menu" class="btn btn-ghost btn-square">
|
||||||
|
<i class="ph-bold ph-circle-dashed text-2xl"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={DOCS_PREFIX}
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-ghost justify-start lg:justify-center lg:btn-sm text-base-content/60 hover:text-primary gap-3 lg:gap-2 w-full lg:w-auto px-3"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<i class="ph-duotone ph-book-open text-lg"></i>
|
||||||
|
<span class="font-bold uppercase tracking-widest">
|
||||||
|
API Reference
|
||||||
|
<sup class="text-xxs opacity-50 ml-0.5">
|
||||||
|
{API_VERSION}.{APP_VERSION}
|
||||||
|
</sup>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="divider my-0 opacity-50 lg:hidden"></div>
|
||||||
|
|
||||||
|
<template x-if="!isUnlocked">
|
||||||
|
<div class="flex flex-col lg:flex-row gap-4 items-start lg:items-center w-full lg:w-auto">
|
||||||
|
<div class="join border border-base-200/50 bg-base-200/50 rounded-xl overflow-hidden focus-within:border-primary transition-colors w-full lg:w-auto">
|
||||||
|
<div class="join-item flex items-center px-4 bg-base-200">
|
||||||
|
<i class="ph-duotone ph-key text-secondary text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex-1" x-data="{ show: false }">
|
||||||
|
<input
|
||||||
|
x-bind:type="show ? 'text' : 'password'"
|
||||||
|
id="apiKey"
|
||||||
|
name="apiKey"
|
||||||
|
x-model="apiKey"
|
||||||
|
aria-label="Master API Key"
|
||||||
|
placeholder="API_KEY"
|
||||||
|
class="input join-item input-sm bg-transparent border-none focus:outline-none w-full lg:w-64 text-xs pr-10 font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="show = !show"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square"
|
||||||
|
>
|
||||||
|
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-base opacity-50' : 'ph-duotone ph-eye text-base opacity-50'"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
x-on:click="unlock(); document.getElementById('mobile-menu').checked = false;"
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary btn-sm join-item px-6"
|
||||||
|
x-bind:disabled="loading"
|
||||||
|
>
|
||||||
|
<i class="ph-duotone ph-lock-key-open text-lg" x-show="!loading"></i>
|
||||||
|
<span class="loading loading-spinner loading-xs" x-show="loading"></span>
|
||||||
|
<span
|
||||||
|
class="ml-1 hidden lg:inline"
|
||||||
|
x-text="loading ? 'Unlocking...' : 'Unlock'"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="isUnlocked">
|
||||||
|
<div class="flex flex-col lg:flex-row items-start lg:items-center gap-4 w-full lg:w-auto">
|
||||||
|
<form
|
||||||
|
class="join border border-base-200/50 bg-base-200/50 rounded-xl overflow-hidden focus-within:border-primary transition-colors w-full lg:w-auto"
|
||||||
|
x-on:submit="$event.preventDefault(); if(isTenantLocked) { isTenantLocked = false; } else { isTenantLocked = true; fetchProviders(); document.getElementById('mobile-menu').checked = false; }"
|
||||||
|
>
|
||||||
|
<div class="join-item flex items-center px-4 bg-base-300 opacity-70">
|
||||||
|
<i class="ph-duotone ph-identification-badge text-xl text-primary"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="tenantId"
|
||||||
|
x-bind:readonly="isTenantLocked"
|
||||||
|
placeholder="Tenant ID"
|
||||||
|
class="input join-item input-sm bg-transparent border-none focus:outline-none flex-1 lg:flex-none lg:w-48 text-xs font-mono transition-opacity"
|
||||||
|
x-bind:class="isTenantLocked ? 'opacity-60 cursor-default' : ''"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-sm join-item px-4"
|
||||||
|
x-bind:class="isTenantLocked ? 'btn-neutral' : 'btn-secondary'"
|
||||||
|
x-bind:title="isTenantLocked ? 'Edit Tenant ID' : 'Apply Tenant ID'"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="text-lg"
|
||||||
|
x-bind:class="isTenantLocked ? 'ph-thin ph-pencil-simple' : 'ph-bold ph-check'"
|
||||||
|
></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button
|
||||||
|
x-on:click="logout(); document.getElementById('mobile-menu').checked = false;"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost text-error hover:bg-error/10 gap-2 w-full lg:w-auto lg:btn-sm px-4 justify-start lg:justify-center"
|
||||||
|
x-bind:disabled="loading"
|
||||||
|
>
|
||||||
|
<i class="ph-bold ph-power text-lg"></i>
|
||||||
|
<span class="font-bold uppercase tracking-wider text-xs">Logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50 z-40 hidden peer-checked:block lg:hidden"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-4 md:p-8 max-w-7xl">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 md:gap-8">
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-4 self-start">
|
||||||
|
<div class="card-body p-6">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<i class="ph-duotone ph-shield-plus text-2xl text-primary mb-1"></i>
|
||||||
|
<div class="w-1 h-6 bg-primary/50 rounded-full"></div>
|
||||||
|
<h2 class="card-title text-xl font-semibold">Configure Provider</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form x-on:submit="saveConfig" class="space-y-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label htmlFor="providerName" class="label py-1">
|
||||||
|
<span class="label-text flex items-center gap-2">
|
||||||
|
Provider ID
|
||||||
|
<span
|
||||||
|
class="tooltip tooltip-top before:text-left"
|
||||||
|
data-tip="Internal name for this service. This will define your login URL (e.g. /auth/trakt/login)."
|
||||||
|
>
|
||||||
|
<i class="ph ph-info opacity-50 cursor-help"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="providerName"
|
||||||
|
x-model="form.providerName"
|
||||||
|
placeholder="e.g. trakt"
|
||||||
|
required
|
||||||
|
class="input input-bordered w-full focus:input-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest">
|
||||||
|
Credentials
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label htmlFor="clientId" class="label py-1">
|
||||||
|
<span class="label-text flex items-center gap-2">
|
||||||
|
Client ID
|
||||||
|
<span
|
||||||
|
class="tooltip tooltip-top before:text-left"
|
||||||
|
data-tip="Found in the 'API' or 'Developer' section of the provider. Sometimes called 'App ID' or 'Consumer Key'."
|
||||||
|
>
|
||||||
|
<i class="ph ph-info opacity-50 cursor-help text-xs"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="clientId"
|
||||||
|
x-model="form.clientId"
|
||||||
|
placeholder="OAuth client id"
|
||||||
|
required
|
||||||
|
class="input input-bordered w-full focus:input-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control" x-data="{ show: false }">
|
||||||
|
<label htmlFor="clientSecret" class="label py-1">
|
||||||
|
<span class="label-text flex items-center gap-2">
|
||||||
|
Client Secret
|
||||||
|
<span
|
||||||
|
class="tooltip tooltip-top before:text-left"
|
||||||
|
data-tip="Found next to the Client ID. This is your private key—never share it or put it in client-side code."
|
||||||
|
>
|
||||||
|
<i class="ph ph-info opacity-50 cursor-help text-xs"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
x-bind:type="show ? 'text' : 'password'"
|
||||||
|
id="clientSecret"
|
||||||
|
x-model="form.clientSecret"
|
||||||
|
placeholder="OAuth client secret"
|
||||||
|
required
|
||||||
|
class="input input-bordered w-full focus:input-primary pr-12"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="show = !show"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square"
|
||||||
|
>
|
||||||
|
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-lg opacity-40' : 'ph-duotone ph-eye text-lg opacity-40'"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest">
|
||||||
|
Endpoints
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label htmlFor="authUrl" class="label py-1">
|
||||||
|
<span class="label-text flex items-center gap-2">
|
||||||
|
Auth URL
|
||||||
|
<span
|
||||||
|
class="tooltip tooltip-top before:text-left"
|
||||||
|
data-tip="The page where users click 'Authorize'. Usually found in OAuth2 docs under 'Endpoints' or 'Authorize'."
|
||||||
|
>
|
||||||
|
<i class="ph ph-info opacity-50 cursor-help text-xs"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="authUrl"
|
||||||
|
x-model="form.authUrl"
|
||||||
|
placeholder="https://trakt.tv/oauth/authorize"
|
||||||
|
required
|
||||||
|
class="input input-bordered w-full focus:input-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label htmlFor="tokenUrl" class="label py-1">
|
||||||
|
<span class="label-text flex items-center gap-2">
|
||||||
|
Token URL
|
||||||
|
<span
|
||||||
|
class="tooltip tooltip-top before:text-left"
|
||||||
|
data-tip="The background API used to trade the code for a token. Usually ends in '/token' or '/access_token'."
|
||||||
|
>
|
||||||
|
<i class="ph ph-info opacity-50 cursor-help text-xs"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="tokenUrl"
|
||||||
|
x-model="form.tokenUrl"
|
||||||
|
placeholder="https://api.trakt.tv/oauth/token"
|
||||||
|
required
|
||||||
|
class="input input-bordered w-full focus:input-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label htmlFor="redirectUri" class="label py-1">
|
||||||
|
<span class="label-text flex items-center gap-2">
|
||||||
|
Redirect URI
|
||||||
|
<span
|
||||||
|
class="tooltip tooltip-top before:text-left"
|
||||||
|
data-tip="The provider will redirect the code to this URI. Copy this URL and paste it into the 'Redirect URI' or 'Callback URL' field in your OAuth provider's settings."
|
||||||
|
>
|
||||||
|
<i class="ph ph-info opacity-50 cursor-help text-xs"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative group">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="redirectUri"
|
||||||
|
x-bind:value="getRedirectUri()"
|
||||||
|
readonly
|
||||||
|
class="input input-bordered w-full pr-12 focus:outline-none cursor-default opacity-80"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="copyToClipboard(getRedirectUri())"
|
||||||
|
class="btn btn-ghost btn-xs absolute right-2 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<i class="ph-duotone ph-copy text-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="label py-0.5">
|
||||||
|
<span class="label-text-alt opacity-40 italic text-xs">
|
||||||
|
Must match provider's callback URL
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label htmlFor="scope" class="label py-1">
|
||||||
|
<span class="label-text flex items-center gap-2">
|
||||||
|
Scope
|
||||||
|
<span
|
||||||
|
class="tooltip tooltip-top before:text-left"
|
||||||
|
data-tip="Determines what data you're allowed to access. Multiple scopes are usually space-separated."
|
||||||
|
>
|
||||||
|
<i class="ph ph-info opacity-50 cursor-help text-xs"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="scope"
|
||||||
|
x-model="form.scope"
|
||||||
|
placeholder="public"
|
||||||
|
class="input input-bordered w-full focus:input-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-full shadow-md"
|
||||||
|
x-bind:disabled="loading"
|
||||||
|
>
|
||||||
|
<i class="ph ph-plus-bold mr-1"></i>
|
||||||
|
Save Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-8 overflow-hidden">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="p-6 pb-4 flex justify-between items-center bg-base-100">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="ph-duotone ph-shipping-container text-2xl text-primary"></i>
|
||||||
|
<div class="w-1 h-6 bg-primary/50 rounded-full"></div>
|
||||||
|
<h2 class="card-title text-xl font-semibold">Provider Registry</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="fetchProviders()"
|
||||||
|
class="btn btn-sm btn-neutral"
|
||||||
|
x-bind:disabled="!isUnlocked || loading"
|
||||||
|
>
|
||||||
|
<i x-bind:class="loading ? 'ph ph-arrows-clockwise animate-spin mr-1' : 'ph ph-arrows-clockwise mr-1'"></i>
|
||||||
|
Refresh List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative min-h-[400px]">
|
||||||
|
<div
|
||||||
|
x-show="loading && providers.length > 0"
|
||||||
|
class="absolute inset-0 bg-base-100/50 backdrop-blur-md z-10 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="!isUnlocked" class="p-20 text-center opacity-30">
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<i class="ph ph-lock-key text-6xl"></i>
|
||||||
|
<p class="font-medium">Enter Master API Key to access registry</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-show="isUnlocked && providers.length === 0 && !loading"
|
||||||
|
class="p-20 text-center opacity-30"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<i class="ph ph-folder-open text-6xl"></i>
|
||||||
|
<p class="font-medium">No providers configured yet</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-show="isUnlocked && providers.length > 0"
|
||||||
|
class="p-6 grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||||
|
>
|
||||||
|
<template x-for="provider in providers" x-bind:key="provider.name">
|
||||||
|
<div class="card bg-base-200/50 border border-base-300 shadow-sm hover:shadow-md transition-all group">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<div class="flex flex-col mb-4">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span
|
||||||
|
x-text="provider.name"
|
||||||
|
class="text-lg font-black text-base-content/90 uppercase"
|
||||||
|
></span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="deleteProvider(provider.name)"
|
||||||
|
class="btn btn-error btn-xs mt-1 opacity-0 group-hover:opacity-40 transition-all duration-300"
|
||||||
|
title="Delete Provider"
|
||||||
|
>
|
||||||
|
<i class="ph-bold ph-trash text-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
x-text="provider.config.clientId"
|
||||||
|
x-bind:title="provider.config.clientId"
|
||||||
|
class="text-xs opacity-40 truncate font-mono"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div x-data="{ show: false }">
|
||||||
|
<div class="text-xs uppercase font-semibold opacity-30 block mb-1">
|
||||||
|
Access Token
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
x-show="provider.status.accessToken"
|
||||||
|
class="flex items-center gap-2 bg-base-100 rounded border border-base-300 p-1 pl-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
x-bind:type="show ? 'text' : 'password'"
|
||||||
|
x-bind:value="provider.status.accessToken"
|
||||||
|
readonly
|
||||||
|
class="bg-transparent border-none outline-none shadow-none focus:ring-0 text-xs flex-1 min-w-0 font-mono"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="show = !show"
|
||||||
|
class="btn btn-ghost btn-xs btn-square"
|
||||||
|
>
|
||||||
|
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-base opacity-50' : 'ph-duotone ph-eye text-base opacity-50'"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="copyToClipboard(provider.status.accessToken)"
|
||||||
|
class="btn btn-ghost btn-xs btn-square"
|
||||||
|
>
|
||||||
|
<i class="ph-duotone ph-copy-simple text-base opacity-50"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
x-show="!provider.status.accessToken"
|
||||||
|
class="h-8 flex items-center px-3 bg-base-300/30 rounded text-xs italic opacity-40"
|
||||||
|
>
|
||||||
|
Not Authenticated
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-data="{ show: false }">
|
||||||
|
<div class="text-xs uppercase font-semibold opacity-30 block mb-1">
|
||||||
|
Refresh Token
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
x-show="provider.status.refreshToken"
|
||||||
|
class="flex items-center gap-2 bg-base-100 rounded border border-base-300 p-1 pl-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
x-bind:type="show ? 'text' : 'password'"
|
||||||
|
x-bind:value="provider.status.refreshToken"
|
||||||
|
readonly
|
||||||
|
class="bg-transparent border-none outline-none shadow-none focus:ring-0 text-xs flex-1 min-w-0 font-mono"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="show = !show"
|
||||||
|
class="btn btn-ghost btn-xs btn-square"
|
||||||
|
>
|
||||||
|
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-base opacity-50' : 'ph-duotone ph-eye text-base opacity-50'"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="copyToClipboard(provider.status.refreshToken)"
|
||||||
|
class="btn btn-ghost btn-xs btn-square"
|
||||||
|
>
|
||||||
|
<i class="ph-duotone ph-copy-simple text-base opacity-50"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
x-show="!provider.status.refreshToken"
|
||||||
|
class="h-8 flex items-center px-3 bg-base-300/30 rounded text-xs italic opacity-40"
|
||||||
|
>
|
||||||
|
Not Authenticated
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider my-3 opacity-10"></div>
|
||||||
|
<div
|
||||||
|
x-show="provider.status.accessToken"
|
||||||
|
class="grid grid-cols-2 gap-4 mb-5"
|
||||||
|
>
|
||||||
|
<div class="p-1 flex flex-col gap-0.5">
|
||||||
|
<span class="text-xxs font-bold opacity-20 uppercase tracking-wide">
|
||||||
|
Last Updated
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
x-text="formatTime(provider.status.lastUpdated)"
|
||||||
|
class="text-xs text-secondary/75 font-bold font-mono tracking-wider opacity-60"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="p-1 flex flex-col gap-0.5 items-end text-right">
|
||||||
|
<span class="text-xxs font-bold opacity-20 uppercase tracking-widest">
|
||||||
|
Expires In
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
x-text="formatExpiry(provider.status.expiresAt)"
|
||||||
|
x-bind:class="isExpired(provider.status.expiresAt) ? 'text-error' : 'text-primary'"
|
||||||
|
class="text-xs font-bold font-mono tracking-wide"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click={`window.open('${AUTH_PREFIX}/' + provider.name + '/login?tenantId=' + encodeURIComponent(tenantId), '_blank')`}
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
|
<i class="ph-bold ph-link"></i> Connect
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="editProvider(provider)"
|
||||||
|
class="btn btn-neutral btn-sm"
|
||||||
|
>
|
||||||
|
<i class="ph-bold ph-pencil-simple"></i> Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="forceRefresh(provider.name)"
|
||||||
|
class="btn btn-secondary w-full"
|
||||||
|
x-bind:disabled="loading || !provider.status.accessToken"
|
||||||
|
>
|
||||||
|
<i class="ph-bold ph-arrows-clockwise text-base mr-1"></i>
|
||||||
|
<span class="text-xs uppercase font-bold tracking-widest">
|
||||||
|
Refresh Tokens
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast toast-center toast-top z-100" x-show="notification.show">
|
||||||
|
<div
|
||||||
|
class="alert shadow-lg border border-base-300"
|
||||||
|
x-bind:class="notification.type === 'error' ? 'alert-error' : 'alert-success'"
|
||||||
|
>
|
||||||
|
<span x-text="notification.message"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Success = (props: { provider: string }) => (
|
||||||
|
<Layout title="Authenticated!">
|
||||||
|
<div class="min-h-[80vh] flex items-center justify-center p-4">
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-300 max-w-md w-full">
|
||||||
|
<div class="card-body items-center text-center p-8 md:p-12">
|
||||||
|
<div class="w-20 h-20 bg-success/10 text-success rounded-2xl flex items-center justify-center mb-6 shadow-inner animate-pulse-slow">
|
||||||
|
<i class="ph-duotone ph-check-circle text-5xl"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="card-title text-3xl font-black tracking-tight mb-2 uppercase">
|
||||||
|
Authenticated!
|
||||||
|
</h2>
|
||||||
|
<p class="text-base-content/60 leading-relaxed">
|
||||||
|
Successfully connected to{" "}
|
||||||
|
<span class="font-bold text-base-content uppercase">{props.provider}</span>. You can now
|
||||||
|
close this window or return to the dashboard.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="divider my-8 opacity-50"></div>
|
||||||
|
|
||||||
|
<div class="card-actions w-full">
|
||||||
|
<a
|
||||||
|
href="/app"
|
||||||
|
class="btn btn-primary btn-block shadow-lg hover:shadow-primary/20 transition-all"
|
||||||
|
>
|
||||||
|
<i class="ph-bold ph-house mr-2"></i>
|
||||||
|
Back to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
dashboardRoutes.get("/", async (c) => {
|
||||||
|
const isUnlocked = getCookie(c, "toknd_api_key") === config.API_KEY;
|
||||||
|
return c.html(<Dashboard isUnlocked={isUnlocked} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
dashboardRoutes.post("/unlock", async (c) => {
|
||||||
|
const { apiKey } = await c.req.json();
|
||||||
|
if (apiKey !== config.API_KEY) {
|
||||||
|
return c.json({ error: "Invalid API Key" }, 401);
|
||||||
|
}
|
||||||
|
setCookie(c, "toknd_api_key", apiKey, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "Strict",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24 * 7, // 1 week
|
||||||
|
});
|
||||||
|
return c.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
dashboardRoutes.post("/logout", async (c) => {
|
||||||
|
deleteCookie(c, "toknd_api_key", { path: "/" });
|
||||||
|
return c.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
dashboardRoutes.get("/success", async (c) => {
|
||||||
|
const provider = c.req.query("provider") || "Provider";
|
||||||
|
return c.html(<Success provider={provider} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { dashboardRoutes };
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const API_VERSION = "v1";
|
||||||
|
export const APP_VERSION = "1.1";
|
||||||
@@ -1,557 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="abyss">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>toknd — Auth Broker Dashboard</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
|
|
||||||
<script src="https://unpkg.com/@phosphor-icons/web@2.1.1"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--font-sans: "DM Sans", sans-serif;
|
|
||||||
--font-mono: "DM Mono", monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-mono,
|
|
||||||
.input-mono,
|
|
||||||
#apiKey,
|
|
||||||
[type="password"],
|
|
||||||
[id^="token-"] {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* UI elements prioritization */
|
|
||||||
.btn, .card-title, .navbar a, .label-text, .tooltip {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="bg-base-200/50 min-h-screen font-sans antialiased text-base-content">
|
|
||||||
<div class="navbar bg-base-100 shadow-sm px-4 md:px-8 border-b border-base-300">
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-primary-content font-semibold text-lg">
|
|
||||||
<i class="ph-duotone ph-fingerprint"></i>
|
|
||||||
</div>
|
|
||||||
<a class="text-xl font-semibold tracking-tight">toknd <span
|
|
||||||
class="text-xs font-normal opacity-50 ml-1">auth
|
|
||||||
broker</span></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-none hidden sm:flex">
|
|
||||||
<div class="join border border-base-300 bg-base-200/50 rounded-xl overflow-hidden focus-within:border-primary transition-colors">
|
|
||||||
<div class="join-item flex items-center px-4 bg-base-100">
|
|
||||||
<i class="ph-duotone ph-key text-primary text-lg animate-pulse-slow"></i>
|
|
||||||
</div>
|
|
||||||
<div class="relative flex-1">
|
|
||||||
<input type="password" id="apiKey" placeholder="API_KEY"
|
|
||||||
class="input join-item input-sm bg-transparent border-none focus:outline-none w-48 lg:w-64 text-xs pr-10 font-mono" />
|
|
||||||
<button type="button" onclick="window.toggleToken('apiKey')"
|
|
||||||
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square">
|
|
||||||
<i class="ph-duotone ph-eye text-base opacity-50" id="eye-apiKey"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button onclick="fetchProviders()" type="button"
|
|
||||||
class="btn btn-primary btn-sm join-item px-6">
|
|
||||||
<i class="ph-duotone ph-lock-key-open text-lg"></i>
|
|
||||||
<span class="ml-1 hidden md:inline">Unlock</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container mx-auto p-4 md:p-8 max-w-7xl">
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 md:gap-8">
|
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-4 self-start">
|
|
||||||
<div class="card-body p-6">
|
|
||||||
<div class="flex items-center gap-2 mb-4">
|
|
||||||
<div class="w-2 h-6 bg-primary rounded-full"></div>
|
|
||||||
<h2 class="card-title text-xl font-semibold">Configure Provider</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="configForm" class="space-y-4">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label py-1">
|
|
||||||
<span class="label-text flex items-center gap-2">
|
|
||||||
Provider ID
|
|
||||||
<div class="tooltip tooltip-top"
|
|
||||||
data-tip="Internal name for this service. This will define your login URL (e.g. /auth/trakt/login).">
|
|
||||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" id="providerName" placeholder="e.g. trakt" required
|
|
||||||
class="input input-bordered w-full focus:input-primary" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest text-xs">Credentials
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label py-1">
|
|
||||||
<span class="label-text flex items-center gap-2">
|
|
||||||
Client ID
|
|
||||||
<div class="tooltip tooltip-top"
|
|
||||||
data-tip="Found in the "API" or "Developer" section of the provider. Sometimes called "App ID" or "Consumer Key".">
|
|
||||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" id="clientId" placeholder="OAuth client id" required
|
|
||||||
class="input input-bordered w-full focus:input-primary" />
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label py-1">
|
|
||||||
<span class="label-text flex items-center gap-2">
|
|
||||||
Client Secret
|
|
||||||
<div class="tooltip tooltip-top"
|
|
||||||
data-tip="Found next to the Client ID. This is your private key—never share it or put it in client-side code.">
|
|
||||||
<i class="ph-duotone ph-info opacity-50 cursor-help"></i>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
|
||||||
<input type="password" id="clientSecret" placeholder="OAuth client secret" required
|
|
||||||
class="input input-bordered w-full focus:input-primary pr-12" />
|
|
||||||
<button type="button" onclick="window.toggleToken('clientSecret')"
|
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square">
|
|
||||||
<i class="ph-duotone ph-eye text-lg opacity-40" id="eye-clientSecret"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest text-xs">Endpoints
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label py-1">
|
|
||||||
<span class="label-text flex items-center gap-2">
|
|
||||||
Auth URL
|
|
||||||
<div class="tooltip tooltip-top"
|
|
||||||
data-tip="The page where users click "Authorize". Usually found in OAuth2 docs under "Endpoints" or "Authorize".">
|
|
||||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input type="url" id="authUrl" placeholder="https://trakt.tv/oauth/authorize" required
|
|
||||||
class="input input-bordered w-full focus:input-primary" />
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label py-1">
|
|
||||||
<span class="label-text flex items-center gap-2">
|
|
||||||
Token URL
|
|
||||||
<div class="tooltip tooltip-top"
|
|
||||||
data-tip="The background API used to trade the code for a token. Usually ends in "/token" or "/access_token".">
|
|
||||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input type="url" id="tokenUrl" placeholder="https://api.trakt.tv/oauth/token" required
|
|
||||||
class="input input-bordered w-full focus:input-primary" />
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label py-1">
|
|
||||||
<span class="label-text flex items-center gap-2">
|
|
||||||
Redirect URI
|
|
||||||
<div class="tooltip tooltip-top"
|
|
||||||
data-tip="Copy this URL and paste it into the "Redirect URI" or "Callback URL" field in your OAuth provider's settings.">
|
|
||||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative group">
|
|
||||||
<input type="url" id="redirectUri" readonly
|
|
||||||
class="input input-bordered w-full pr-12 focus:outline-none cursor-default opacity-80" />
|
|
||||||
<button type="button" onclick="copyRedirectUri()"
|
|
||||||
class="btn btn-ghost btn-xs absolute right-2 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-primary transition-colors">
|
|
||||||
<i class="ph ph-copy text-xs"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<label class="label py-0.5">
|
|
||||||
<span class="label-text-alt opacity-40 italic text-xs">Must match provider's
|
|
||||||
callback URL</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label py-1">
|
|
||||||
<span class="label-text flex items-center gap-2">
|
|
||||||
Scope
|
|
||||||
<div class="tooltip tooltip-top"
|
|
||||||
data-tip="Determines what data you're allowed to access. Multiple scopes are usually space-separated.">
|
|
||||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" id="scope" placeholder="public"
|
|
||||||
class="input input-bordered w-full focus:input-primary" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-actions pt-4">
|
|
||||||
<button type="submit" class="btn btn-primary w-full shadow-md">
|
|
||||||
<i class="ph ph-plus-bold mr-1"></i>
|
|
||||||
Save Configuration
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-8 overflow-hidden">
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<div class="p-6 pb-4 flex justify-between items-center bg-base-100">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="w-2 h-6 bg-primary rounded-full"></div>
|
|
||||||
<h2 class="card-title text-xl font-semibold">Provider Registry</h2>
|
|
||||||
</div>
|
|
||||||
<button type="button" onclick="fetchProviders()" class="btn btn-sm btn-ghost border-base-300">
|
|
||||||
<i id="refreshIcon" class="ph ph-arrows-clockwise mr-1"></i>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="relative min-h-[400px]">
|
|
||||||
<!-- Loading Overlay -->
|
|
||||||
<div id="registryLoading"
|
|
||||||
class="absolute inset-0 bg-base-100/50 backdrop-blur-[2px] z-10 flex items-center justify-center hidden transition-all duration-300">
|
|
||||||
<div class="flex flex-col items-center gap-4">
|
|
||||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-widest opacity-40">Syncing
|
|
||||||
Registry...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="providerRegistry" class="p-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div class="col-span-full text-center py-20 opacity-30">
|
|
||||||
<div class="flex flex-col items-center gap-3">
|
|
||||||
<i class="ph ph-lock-key text-6xl"></i>
|
|
||||||
<p class="font-medium">Enter Master API Key to access registry</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toast toast-end" id="notificationToast" style="display: none; z-index: 100;">
|
|
||||||
<div class="alert shadow-lg border border-base-300">
|
|
||||||
<span id="notificationMessage"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const apiKeyInput = document.getElementById('apiKey');
|
|
||||||
const configForm = document.getElementById('configForm');
|
|
||||||
const redirectUriInput = document.getElementById('redirectUri');
|
|
||||||
const providerRegistry = document.getElementById('providerRegistry');
|
|
||||||
const registryLoading = document.getElementById('registryLoading');
|
|
||||||
|
|
||||||
function setDefaultRedirectUri() {
|
|
||||||
if (redirectUriInput) {
|
|
||||||
redirectUriInput.value = `${window.location.origin}/auth/callback`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.copyRedirectUri = () => {
|
|
||||||
if (redirectUriInput) {
|
|
||||||
navigator.clipboard.writeText(redirectUriInput.value).then(() => {
|
|
||||||
showNotification('Redirect URI copied!');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setDefaultRedirectUri();
|
|
||||||
|
|
||||||
let providerData = {};
|
|
||||||
let tokenStatus = {};
|
|
||||||
|
|
||||||
function showNotification(message, type = 'success') {
|
|
||||||
const toast = document.getElementById('notificationToast');
|
|
||||||
const alert = toast.querySelector('.alert');
|
|
||||||
const msgSpan = document.getElementById('notificationMessage');
|
|
||||||
|
|
||||||
alert.className = `alert alert-${type} shadow-lg`;
|
|
||||||
msgSpan.textContent = message;
|
|
||||||
toast.style.display = 'block';
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.style.display = 'none';
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchProviders() {
|
|
||||||
const apiKey = apiKeyInput.value.trim();
|
|
||||||
const refreshIcon = document.getElementById('refreshIcon');
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
providerRegistry.innerHTML = `
|
|
||||||
<div class="col-span-full text-center py-20 opacity-30">
|
|
||||||
<div class="flex flex-col items-center gap-3">
|
|
||||||
<i class="ph-duotone ph-fingerprint text-6xl"></i>
|
|
||||||
<p class="font-medium italic">Unlock toknd to access the broker registry</p>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (refreshIcon) refreshIcon.classList.add('animate-spin');
|
|
||||||
if (registryLoading) registryLoading.classList.remove('hidden');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const configRes = await fetch('/api/config', {
|
|
||||||
headers: { 'Authorization': `Bearer ${apiKey}` }
|
|
||||||
});
|
|
||||||
if (!configRes.ok) throw new Error('Unauthorized or missing API Key');
|
|
||||||
providerData = await configRes.json();
|
|
||||||
|
|
||||||
const statusRes = await fetch('/api/status', {
|
|
||||||
headers: { 'Authorization': `Bearer ${apiKey}` }
|
|
||||||
});
|
|
||||||
if (statusRes.ok) {
|
|
||||||
tokenStatus = await statusRes.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
renderRegistry();
|
|
||||||
localStorage.setItem('toknd_api_key', apiKey);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
providerRegistry.innerHTML = `<div class="col-span-full text-center text-error py-12 font-medium">${error.message}</div>`;
|
|
||||||
} finally {
|
|
||||||
if (refreshIcon) refreshIcon.classList.remove('animate-spin');
|
|
||||||
if (registryLoading) registryLoading.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimeAgo(dateString) {
|
|
||||||
if (!dateString) return '<span class="opacity-30">Never</span>';
|
|
||||||
const date = new Date(dateString);
|
|
||||||
const now = new Date();
|
|
||||||
const diffInSeconds = Math.floor((now - date) / 1000);
|
|
||||||
|
|
||||||
if (diffInSeconds < 60) return 'Just now';
|
|
||||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
|
|
||||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
|
|
||||||
return date.toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRegistry() {
|
|
||||||
providerRegistry.innerHTML = '';
|
|
||||||
const entries = Object.entries(providerData);
|
|
||||||
|
|
||||||
if (entries.length === 0) {
|
|
||||||
providerRegistry.innerHTML = '<div class="col-span-full text-center py-16 opacity-50 font-medium italic">No providers configured yet.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, config] of entries) {
|
|
||||||
const status = tokenStatus[name] || { accessToken: null, refreshToken: null, lastUpdated: null };
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = "card bg-base-200/50 border border-base-300 shadow-sm hover:shadow-md transition-all group";
|
|
||||||
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="card-body p-5">
|
|
||||||
<div class="flex flex-col mb-4">
|
|
||||||
<span class="text-lg font-black text-base-content/90 uppercase">${name}</span>
|
|
||||||
<span class="text-xs opacity-40 truncate font-mono" title="${config.clientId}">${config.clientId}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="text-xs uppercase font-semibold opacity-30 block mb-1">Access Token</label>
|
|
||||||
${renderTokenField(name, 'access', status.accessToken)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-xs uppercase font-semibold opacity-30 block mb-1">Refresh Token</label>
|
|
||||||
${renderTokenField(name, 'refresh', status.refreshToken)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider my-3 opacity-10"></div>
|
|
||||||
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<span class="text-xs font-semibold opacity-30 uppercase">Last Updated</span>
|
|
||||||
<span class="text-xs font-medium opacity-60">${formatTimeAgo(status.lastUpdated)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<a href="/auth/${name}/login" target="_blank" class="btn btn-primary btn-sm shadow-sm">
|
|
||||||
<i class="ph-bold ph-link"></i> Connect
|
|
||||||
</a>
|
|
||||||
<button type="button" onclick="window.editProvider('${name}')" class="btn btn-outline btn-sm">
|
|
||||||
<i class="ph-bold ph-pencil-simple"></i> Edit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button type="button" onclick="window.forceRefresh('${name}')" id="btn-refresh-${name}"
|
|
||||||
class="btn btn-ghost btn-sm border border-base-300 w-full">
|
|
||||||
<i class="ph-duotone ph-arrows-clockwise text-base mr-1" id="icon-refresh-${name}"></i>
|
|
||||||
<span class="text-[10px] uppercase font-bold tracking-wider">Manual Refresh</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
providerRegistry.appendChild(card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTokenField(provider, type, value) {
|
|
||||||
if (!value) {
|
|
||||||
return `<div class="h-8 flex items-center px-3 bg-base-300/30 rounded text-xs italic opacity-40">Not Authenticated</div>`;
|
|
||||||
}
|
|
||||||
const id = `token-${provider}-${type}`;
|
|
||||||
return `
|
|
||||||
<div class="flex items-center gap-2 bg-base-100 rounded border border-base-300 p-1 pl-3 group/token transition-all focus-within:border-primary/50">
|
|
||||||
<input type="password" id="${id}" value="${value}" readonly
|
|
||||||
class="bg-transparent border-none outline-none shadow-none focus:ring-0 text-xs flex-1 min-w-0 font-mono" />
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<button onclick="window.toggleToken('${id}')" class="btn btn-ghost btn-xs btn-square">
|
|
||||||
<i class="ph-duotone ph-eye text-base opacity-50" id="eye-${id}"></i>
|
|
||||||
</button>
|
|
||||||
<button onclick="window.copyToken('${value}')" class="btn btn-ghost btn-xs btn-square">
|
|
||||||
<i class="ph-duotone ph-copy-simple text-base opacity-50"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.toggleToken = (id) => {
|
|
||||||
const input = document.getElementById(id);
|
|
||||||
const eye = document.getElementById(`eye-${id}`);
|
|
||||||
if (input.type === 'password') {
|
|
||||||
input.type = 'text';
|
|
||||||
eye.className = 'ph-duotone ph-eye-slash text-base';
|
|
||||||
} else {
|
|
||||||
input.type = 'password';
|
|
||||||
eye.className = 'ph-duotone ph-eye text-base opacity-50';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.copyToken = (value) => {
|
|
||||||
navigator.clipboard.writeText(value).then(() => {
|
|
||||||
showNotification('Token copied to clipboard!');
|
|
||||||
}).catch(_err => {
|
|
||||||
showNotification('Failed to copy token', 'error');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
window.editProvider = (name) => {
|
|
||||||
const config = providerData[name];
|
|
||||||
if (!config) return;
|
|
||||||
|
|
||||||
document.getElementById('providerName').value = name;
|
|
||||||
document.getElementById('clientId').value = config.clientId;
|
|
||||||
document.getElementById('clientSecret').value = config.clientSecret;
|
|
||||||
document.getElementById('authUrl').value = config.authUrl;
|
|
||||||
document.getElementById('tokenUrl').value = config.tokenUrl;
|
|
||||||
setDefaultRedirectUri();
|
|
||||||
document.getElementById('scope').value = config.scope;
|
|
||||||
|
|
||||||
document.getElementById('configForm').scrollIntoView({ behavior: 'smooth' });
|
|
||||||
document.getElementById('providerName').focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.forceRefresh = async (name) => {
|
|
||||||
const apiKey = apiKeyInput.value.trim();
|
|
||||||
const btn = document.getElementById(`btn-refresh-${name}`);
|
|
||||||
const icon = document.getElementById(`icon-refresh-${name}`);
|
|
||||||
|
|
||||||
if (btn) btn.classList.add('btn-disabled', 'opacity-50');
|
|
||||||
if (icon) icon.classList.add('animate-spin');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/refresh/${name}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Authorization': `Bearer ${apiKey}` }
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok) throw new Error(data.error || 'Refresh failed');
|
|
||||||
|
|
||||||
showNotification(`${name} tokens refreshed successfully!`);
|
|
||||||
// Update local data and re-render
|
|
||||||
tokenStatus[name] = data.status;
|
|
||||||
renderRegistry();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Refresh error:', error);
|
|
||||||
showNotification(error.message, 'error');
|
|
||||||
} finally {
|
|
||||||
if (btn) btn.classList.remove('btn-disabled', 'opacity-50');
|
|
||||||
if (icon) icon.classList.remove('animate-spin');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
configForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const apiKey = apiKeyInput.value.trim();
|
|
||||||
if (!apiKey) {
|
|
||||||
showNotification('Please enter your Master API Key', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = document.getElementById('providerName').value.trim();
|
|
||||||
const config = {
|
|
||||||
clientId: document.getElementById('clientId').value.trim(),
|
|
||||||
clientSecret: document.getElementById('clientSecret').value.trim(),
|
|
||||||
authUrl: document.getElementById('authUrl').value.trim(),
|
|
||||||
tokenUrl: document.getElementById('tokenUrl').value.trim(),
|
|
||||||
redirectUri: document.getElementById('redirectUri').value.trim() || undefined,
|
|
||||||
scope: document.getElementById('scope').value.trim(),
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/config/${name}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${apiKey}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify(config)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error(await response.text());
|
|
||||||
|
|
||||||
showNotification('Configuration saved successfully!');
|
|
||||||
fetchProviders();
|
|
||||||
configForm.reset();
|
|
||||||
setDefaultRedirectUri();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving config:', error);
|
|
||||||
showNotification(error.message, 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const savedKey = localStorage.getItem('toknd_api_key');
|
|
||||||
if (savedKey) {
|
|
||||||
apiKeyInput.value = savedKey;
|
|
||||||
fetchProviders();
|
|
||||||
}
|
|
||||||
setDefaultRedirectUri();
|
|
||||||
});
|
|
||||||
|
|
||||||
apiKeyInput.addEventListener('keypress', (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
fetchProviders();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
document.addEventListener("alpine:init", () => {
|
||||||
|
const formatDate = (date) => {
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timestamp) => {
|
||||||
|
if (!timestamp) return "Never";
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const diff = Math.floor((Date.now() - date) / 1000);
|
||||||
|
|
||||||
|
if (diff < 60) return "Just now";
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return formatDate(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatExpiry = (timestamp) => {
|
||||||
|
if (!timestamp) return "Expired";
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const diff = Math.floor((date - Date.now()) / 1000);
|
||||||
|
|
||||||
|
if (diff <= 0) return "Expired";
|
||||||
|
if (diff < 60) return `${diff}s`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||||
|
return `${formatDate(date)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Alpine.data(
|
||||||
|
"dashboard",
|
||||||
|
({ initialIsUnlocked, apiPrefix, authPrefix, docsPrefix, apiVersion, appVersion }) => ({
|
||||||
|
apiKey: "",
|
||||||
|
tenantId: localStorage.getItem("toknd_tenant_id") || "default",
|
||||||
|
isTenantLocked: !!localStorage.getItem("toknd_tenant_id"),
|
||||||
|
isUnlocked: initialIsUnlocked,
|
||||||
|
apiPrefix,
|
||||||
|
authPrefix,
|
||||||
|
docsPrefix,
|
||||||
|
apiVersion,
|
||||||
|
appVersion,
|
||||||
|
loading: false,
|
||||||
|
providers: [],
|
||||||
|
form: {
|
||||||
|
providerName: "",
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
authUrl: "",
|
||||||
|
tokenUrl: "",
|
||||||
|
scope: "public",
|
||||||
|
},
|
||||||
|
notification: {
|
||||||
|
show: false,
|
||||||
|
message: "",
|
||||||
|
type: "success",
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.$watch("tenantId", (val) => {
|
||||||
|
localStorage.setItem("toknd_tenant_id", val);
|
||||||
|
});
|
||||||
|
if (this.isUnlocked) {
|
||||||
|
this.fetchProviders();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async unlock() {
|
||||||
|
if (!this.apiKey) return;
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/app/unlock", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ apiKey: this.apiKey }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Invalid API Key");
|
||||||
|
|
||||||
|
this.isUnlocked = true;
|
||||||
|
await this.fetchProviders();
|
||||||
|
this.apiKey = ""; // Clear after success
|
||||||
|
} catch (err) {
|
||||||
|
this.showNotification(err.message, "error");
|
||||||
|
this.isUnlocked = false;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
await fetch("/app/logout", { method: "POST" });
|
||||||
|
this.isUnlocked = false;
|
||||||
|
this.providers = [];
|
||||||
|
this.showNotification("Logged out successfully");
|
||||||
|
} catch (err) {
|
||||||
|
this.showNotification(`Logout failed: ${err.message}`, "error");
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchProviders() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const [configRes, statusRes] = await Promise.all([
|
||||||
|
fetch(`${this.apiPrefix}/config`),
|
||||||
|
fetch(`${this.apiPrefix}/status`, {
|
||||||
|
headers: { "x-tenant-id": this.tenantId },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (configRes.status === 401 || statusRes.status === 401) {
|
||||||
|
return this.handleSessionExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!configRes.ok || !statusRes.ok) throw new Error("Failed to fetch data");
|
||||||
|
|
||||||
|
const [config, status] = await Promise.all([configRes.json(), statusRes.json()]);
|
||||||
|
this.providers = this.mapProviders(config, status);
|
||||||
|
} catch (err) {
|
||||||
|
this.showNotification(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mapProviders(config, status) {
|
||||||
|
return Object.entries(config).map(([name, cfg]) => ({
|
||||||
|
name,
|
||||||
|
config: cfg,
|
||||||
|
status: status[name] || {
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
lastUpdated: null,
|
||||||
|
expiresAt: null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSessionExpired() {
|
||||||
|
this.isUnlocked = false;
|
||||||
|
this.providers = [];
|
||||||
|
this.showNotification("Session expired", "error");
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveConfig() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.apiPrefix}/config/${this.form.providerName}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(this.form),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
this.isUnlocked = false;
|
||||||
|
throw new Error("Session expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Failed to save");
|
||||||
|
|
||||||
|
this.showNotification("Saved successfully");
|
||||||
|
await this.fetchProviders();
|
||||||
|
|
||||||
|
this.form = {
|
||||||
|
providerName: "",
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
authUrl: "",
|
||||||
|
tokenUrl: "",
|
||||||
|
scope: "public",
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
this.showNotification(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async forceRefresh(name) {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.apiPrefix}/refresh/${name}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-tenant-id": this.tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
return this.handleSessionExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Refresh failed");
|
||||||
|
|
||||||
|
this.showNotification(`Refreshed ${name}`);
|
||||||
|
await this.fetchProviders();
|
||||||
|
} catch (err) {
|
||||||
|
this.showNotification(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteProvider(name) {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Are you sure you want to delete ${name}? This will also remove all associated tokens.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.apiPrefix}/config/${name}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
return this.handleSessionExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Delete failed");
|
||||||
|
|
||||||
|
this.showNotification(`Deleted ${name}`);
|
||||||
|
await this.fetchProviders();
|
||||||
|
} catch (err) {
|
||||||
|
this.showNotification(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
editProvider(provider) {
|
||||||
|
this.form = {
|
||||||
|
providerName: provider.name,
|
||||||
|
clientId: provider.config.clientId,
|
||||||
|
clientSecret: provider.config.clientSecret,
|
||||||
|
authUrl: provider.config.authUrl,
|
||||||
|
tokenUrl: provider.config.tokenUrl,
|
||||||
|
scope: provider.config.scope,
|
||||||
|
};
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
},
|
||||||
|
|
||||||
|
getRedirectUri() {
|
||||||
|
return `${window.location.origin}${this.authPrefix}/${this.form.providerName || "{provider}"}/callback`;
|
||||||
|
},
|
||||||
|
|
||||||
|
copyToClipboard(text) {
|
||||||
|
if (!text) return;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
this.showNotification("Copied");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
showNotification(message, type = "success") {
|
||||||
|
this.notification = { show: true, message, type };
|
||||||
|
setTimeout(() => {
|
||||||
|
this.notification.show = false;
|
||||||
|
}, 3000);
|
||||||
|
},
|
||||||
|
|
||||||
|
formatTime(timestamp) {
|
||||||
|
return formatTime(timestamp);
|
||||||
|
},
|
||||||
|
|
||||||
|
formatExpiry(timestamp) {
|
||||||
|
return formatExpiry(timestamp);
|
||||||
|
},
|
||||||
|
|
||||||
|
isExpired(timestamp) {
|
||||||
|
if (!timestamp) return true;
|
||||||
|
return new Date(timestamp) < new Date();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="abyss">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>toknd — Authentication Successful</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
|
|
||||||
<script src="https://unpkg.com/@phosphor-icons/web@2.1.1"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: "DM Sans", sans-serif;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
.font-mono {
|
|
||||||
font-family: "DM Mono", monospace;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="bg-base-200/50 min-h-screen flex items-center justify-center p-4 antialiased">
|
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-300 max-w-md w-full">
|
|
||||||
<div class="card-body items-center text-center p-8 md:p-12">
|
|
||||||
<div
|
|
||||||
class="w-20 h-20 bg-success/10 text-success rounded-2xl flex items-center justify-center mb-6 shadow-inner animate-pulse">
|
|
||||||
<i class="ph ph-check-circle text-5xl"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 class="card-title text-3xl font-black tracking-tight mb-2">Authenticated!</h2>
|
|
||||||
<p class="text-base-content/60 leading-relaxed">
|
|
||||||
Successfully connected to <span class="font-bold text-base-content">__PROVIDER_NAME__</span>.
|
|
||||||
You can now close this window or return to the dashboard.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="divider my-8 opacity-50"></div>
|
|
||||||
|
|
||||||
<div class="card-actions w-full">
|
|
||||||
<a href="/app" class="btn btn-primary btn-block shadow-lg hover:shadow-primary/20 transition-all">
|
|
||||||
<i class="ph ph-house-bold mr-2"></i>
|
|
||||||
Back to Dashboard
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, it, mock } from "bun:test";
|
||||||
|
import { ConfigManager } from "../../src/core/ConfigManager";
|
||||||
|
|
||||||
|
describe("ConfigManager", () => {
|
||||||
|
it("should save and retrieve provider configuration", async () => {
|
||||||
|
const storage: Record<string, string> = {};
|
||||||
|
const redis = {
|
||||||
|
set: mock((key: string, val: string) => {
|
||||||
|
storage[key] = val;
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
get: mock((key: string) => Promise.resolve(storage[key] || null)),
|
||||||
|
};
|
||||||
|
const manager = new ConfigManager(redis as any);
|
||||||
|
const traktConfig = {
|
||||||
|
clientId: "trakt-client-id",
|
||||||
|
clientSecret: "trakt-client-secret",
|
||||||
|
authUrl: "https://trakt.tv/oauth/authorize",
|
||||||
|
tokenUrl: "https://api.trakt.tv/oauth/token",
|
||||||
|
scope: "public",
|
||||||
|
};
|
||||||
|
|
||||||
|
await manager.setProviderConfig("trakt", traktConfig);
|
||||||
|
const retrieved = await manager.getProviderConfig("trakt");
|
||||||
|
|
||||||
|
expect(retrieved).toEqual(traktConfig as any);
|
||||||
|
expect(redis.set).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return all providers", async () => {
|
||||||
|
const redis = {
|
||||||
|
keys: mock(() => Promise.resolve(["config:trakt", "config:github"])),
|
||||||
|
get: mock((key: string) =>
|
||||||
|
Promise.resolve(
|
||||||
|
JSON.stringify({
|
||||||
|
clientId: `${key}-id`,
|
||||||
|
clientSecret: "secret",
|
||||||
|
authUrl: "https://auth.com",
|
||||||
|
tokenUrl: "https://token.com",
|
||||||
|
scope: "all",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const manager = new ConfigManager(redis as any);
|
||||||
|
|
||||||
|
const providers = await manager.getAllProviders();
|
||||||
|
|
||||||
|
expect(Object.keys(providers)).toHaveLength(2);
|
||||||
|
expect(providers.trakt.clientId).toBe("config:trakt-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for non-existent provider", async () => {
|
||||||
|
const redis = { get: mock(() => Promise.resolve(null)) };
|
||||||
|
const manager = new ConfigManager(redis as any);
|
||||||
|
|
||||||
|
const config = await manager.getProviderConfig("missing-provider");
|
||||||
|
|
||||||
|
expect(config).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should delete provider configuration and all tenant tokens", async () => {
|
||||||
|
const redis = {
|
||||||
|
del: mock(() => Promise.resolve()),
|
||||||
|
keys: mock(() =>
|
||||||
|
Promise.resolve([
|
||||||
|
"tenant:1:provider:trakt:access_token",
|
||||||
|
"tenant:2:provider:trakt:access_token",
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const manager = new ConfigManager(redis as any);
|
||||||
|
|
||||||
|
await manager.deleteProviderConfig("trakt");
|
||||||
|
|
||||||
|
expect(redis.del).toHaveBeenCalledWith("config:trakt");
|
||||||
|
expect(redis.keys).toHaveBeenCalledWith("tenant:*:provider:trakt:*");
|
||||||
|
expect(redis.del).toHaveBeenCalledWith(
|
||||||
|
"tenant:1:provider:trakt:access_token",
|
||||||
|
"tenant:2:provider:trakt:access_token",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,64 +2,91 @@ import { describe, expect, it, mock } from "bun:test";
|
|||||||
import { TokenManager } from "../../src/core/TokenManager";
|
import { TokenManager } from "../../src/core/TokenManager";
|
||||||
|
|
||||||
describe("TokenManager", () => {
|
describe("TokenManager", () => {
|
||||||
|
const tenantId = "test-tenant";
|
||||||
|
|
||||||
it("should return token from redis if available", async () => {
|
it("should return token from redis if available", async () => {
|
||||||
const redisMock = { get: mock(() => Promise.resolve("valid_token")) };
|
const redis = { get: mock(() => Promise.resolve("active-access-token")) };
|
||||||
const manager = new TokenManager(redisMock as any, {} as any);
|
const manager = new TokenManager(redis as any, {} as any);
|
||||||
const token = await manager.getAccessToken("trakt");
|
|
||||||
expect(token).toBe("valid_token");
|
const token = await manager.getAccessToken(tenantId, "trakt");
|
||||||
|
|
||||||
|
expect(token).toBe("active-access-token");
|
||||||
|
expect(redis.get).toHaveBeenCalledWith(`tenant:${tenantId}:provider:trakt:access_token`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should refresh token if access token is missing but refresh token exists", async () => {
|
it("should refresh token if access token is missing but refresh token exists", async () => {
|
||||||
const redisMock = {
|
const redis = {
|
||||||
get: mock((key: string) => Promise.resolve(key.includes("refresh") ? "refresh_token" : null)),
|
get: mock((key) => Promise.resolve(key.includes("refresh") ? "valid-refresh-token" : null)),
|
||||||
set: mock(() => Promise.resolve()),
|
set: mock(() => Promise.resolve()),
|
||||||
};
|
};
|
||||||
const providerMock = {
|
const provider = {
|
||||||
refreshToken: mock(() =>
|
refreshToken: mock(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
accessToken: "new_token",
|
accessToken: "newly-refreshed-access-token",
|
||||||
refreshToken: "new_refresh",
|
refreshToken: "newly-refreshed-refresh-token",
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
const manager = new TokenManager(redisMock as any, providerMock as any);
|
const manager = new TokenManager(redis as any, provider as any);
|
||||||
const token = await manager.getAccessToken("trakt");
|
|
||||||
expect(token).toBe("new_token");
|
const token = await manager.getAccessToken(tenantId, "trakt");
|
||||||
expect(redisMock.set).toHaveBeenCalled();
|
|
||||||
|
expect(token).toBe("newly-refreshed-access-token");
|
||||||
|
expect(redis.get).toHaveBeenCalledWith(`tenant:${tenantId}:provider:trakt:access_token`);
|
||||||
|
expect(redis.get).toHaveBeenCalledWith(`tenant:${tenantId}:provider:trakt:refresh_token`);
|
||||||
|
expect(redis.set).toHaveBeenCalledWith(
|
||||||
|
`tenant:${tenantId}:provider:trakt:access_token`,
|
||||||
|
"newly-refreshed-access-token",
|
||||||
|
"EX",
|
||||||
|
3600,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return null if no tokens are found", async () => {
|
it("should return null if no tokens are found", async () => {
|
||||||
const redisMock = { get: mock(() => Promise.resolve(null)) };
|
const redis = { get: mock(() => Promise.resolve(null)) };
|
||||||
const manager = new TokenManager(redisMock as any, {} as any);
|
const manager = new TokenManager(redis as any, {} as any);
|
||||||
const token = await manager.getAccessToken("trakt");
|
|
||||||
|
const token = await manager.getAccessToken(tenantId, "trakt");
|
||||||
|
|
||||||
expect(token).toBeNull();
|
expect(token).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should refresh token via forceRefresh", async () => {
|
it("should refresh token via refreshAccessToken", async () => {
|
||||||
const redisMock = {
|
const redis = {
|
||||||
get: mock(() => Promise.resolve("refresh_token")),
|
get: mock(() => Promise.resolve("existing-refresh-token")),
|
||||||
set: mock(() => Promise.resolve()),
|
set: mock(() => Promise.resolve()),
|
||||||
};
|
};
|
||||||
const providerMock = {
|
const provider = {
|
||||||
refreshToken: mock(() =>
|
refreshToken: mock(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
accessToken: "forced_token",
|
accessToken: "manually-refreshed-access-token",
|
||||||
refreshToken: "new_refresh",
|
refreshToken: "manually-refreshed-refresh-token",
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
const manager = new TokenManager(redisMock as any, providerMock as any);
|
const manager = new TokenManager(redis as any, provider as any);
|
||||||
const token = await manager.forceRefresh("trakt");
|
|
||||||
expect(token).toBe("forced_token");
|
const token = await manager.refreshAccessToken(tenantId, "trakt");
|
||||||
expect(providerMock.refreshToken).toHaveBeenCalledWith("refresh_token");
|
|
||||||
|
expect(token).toBe("manually-refreshed-access-token");
|
||||||
|
expect(redis.get).toHaveBeenCalledWith(`tenant:${tenantId}:provider:trakt:refresh_token`);
|
||||||
|
expect(provider.refreshToken).toHaveBeenCalledWith("existing-refresh-token");
|
||||||
|
expect(redis.set).toHaveBeenCalledWith(
|
||||||
|
`tenant:${tenantId}:provider:trakt:access_token`,
|
||||||
|
"manually-refreshed-access-token",
|
||||||
|
"EX",
|
||||||
|
3600,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return null in forceRefresh if no refresh token is found", async () => {
|
it("should return null in refreshAccessToken if no refresh token is found", async () => {
|
||||||
const redisMock = { get: mock(() => Promise.resolve(null)) };
|
const redis = { get: mock(() => Promise.resolve(null)) };
|
||||||
const manager = new TokenManager(redisMock as any, {} as any);
|
const manager = new TokenManager(redis as any, {} as any);
|
||||||
const token = await manager.forceRefresh("trakt");
|
|
||||||
|
const token = await manager.refreshAccessToken(tenantId, "trakt");
|
||||||
|
|
||||||
expect(token).toBeNull();
|
expect(token).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { describe, expect, it, spyOn } from "bun:test";
|
||||||
|
import { API_PREFIX } from "../../src/constants";
|
||||||
|
import { redis } from "../../src/core/RedisClient";
|
||||||
|
import { app } from "../../src/index";
|
||||||
|
|
||||||
|
describe("API Integration", () => {
|
||||||
|
const mockTraktConfig = JSON.stringify({
|
||||||
|
clientId: "trakt-client-id",
|
||||||
|
clientSecret: "trakt-client-secret",
|
||||||
|
authUrl: "https://trakt.tv/oauth/authorize",
|
||||||
|
tokenUrl: "https://api.trakt.tv/oauth/token",
|
||||||
|
scope: "public",
|
||||||
|
});
|
||||||
|
|
||||||
|
const tenantId = "test-tenant";
|
||||||
|
|
||||||
|
it("should return 401 if API Key is missing", async () => {
|
||||||
|
const res = await app.request(`${API_PREFIX}/status`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ error: "Missing or invalid authorization" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 200 for health check (no auth needed)", async () => {
|
||||||
|
const res = await app.request("/health");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.status).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 503 for health check if redis is down", async () => {
|
||||||
|
(redis as any).status = "connecting";
|
||||||
|
|
||||||
|
const res = await app.request("/health");
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(body.status).toBe("error");
|
||||||
|
expect(body.redis).toBe("connecting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 400 if X-Tenant-ID is missing", async () => {
|
||||||
|
const res = await app.request(`${API_PREFIX}/status`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 200 for status with valid API Key and X-Tenant-ID", async () => {
|
||||||
|
(redis.keys as any).mockReturnValue(Promise.resolve(["config:trakt"]));
|
||||||
|
(redis.get as any).mockImplementation((key) => {
|
||||||
|
if (key.includes("config")) return Promise.resolve(mockTraktConfig);
|
||||||
|
if (key.includes(`tenant:${tenantId}:provider:trakt:access_token`))
|
||||||
|
return Promise.resolve("current-access-token");
|
||||||
|
if (key.includes(`tenant:${tenantId}:provider:trakt:refresh_token`))
|
||||||
|
return Promise.resolve("current-refresh-token");
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request(`${API_PREFIX}/status`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
"X-Tenant-ID": tenantId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.trakt).toBeDefined();
|
||||||
|
expect(body.trakt.accessToken).toBe("current-access-token");
|
||||||
|
expect(redis.get).toHaveBeenCalledWith(`tenant:${tenantId}:provider:trakt:access_token`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return token for a configured provider with X-Tenant-ID", async () => {
|
||||||
|
(redis.get as any).mockImplementation((key) => {
|
||||||
|
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
|
||||||
|
if (key.includes(`tenant:${tenantId}:provider:trakt:access_token`))
|
||||||
|
return Promise.resolve("trakt-active-token");
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request(`${API_PREFIX}/token/trakt`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
"X-Tenant-ID": tenantId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.access_token).toBe("trakt-active-token");
|
||||||
|
expect(redis.get).toHaveBeenCalledWith(`tenant:${tenantId}:provider:trakt:access_token`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should successfully refresh a token with X-Tenant-ID", async () => {
|
||||||
|
(redis.get as any).mockImplementation((key) => {
|
||||||
|
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
|
||||||
|
if (key.includes(`tenant:${tenantId}:provider:trakt:refresh_token`))
|
||||||
|
return Promise.resolve("old-refresh-token");
|
||||||
|
return Promise.resolve("new-access-token-from-refresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
spyOn(globalThis, "fetch").mockImplementation(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
access_token: "new-access-token-from-fetch",
|
||||||
|
refresh_token: "new-refresh-token-from-fetch",
|
||||||
|
expires_in: 3600,
|
||||||
|
}),
|
||||||
|
} as any),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request(`${API_PREFIX}/refresh/trakt`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
"X-Tenant-ID": tenantId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.status.accessToken).toBe("new-access-token-from-refresh");
|
||||||
|
expect(redis.get).toHaveBeenCalledWith(`tenant:${tenantId}:provider:trakt:refresh_token`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it, spyOn } from "bun:test";
|
||||||
|
import { AUTH_PREFIX } from "../../src/constants";
|
||||||
|
import { redis } from "../../src/core/RedisClient";
|
||||||
|
import { app } from "../../src/index";
|
||||||
|
|
||||||
|
describe("Auth Integration", () => {
|
||||||
|
const mockProviderConfig = JSON.stringify({
|
||||||
|
clientId: "trakt-client-id",
|
||||||
|
clientSecret: "trakt-client-secret",
|
||||||
|
authUrl: "https://trakt.tv/oauth/authorize",
|
||||||
|
tokenUrl: "https://api.trakt.tv/oauth/token",
|
||||||
|
scope: "public",
|
||||||
|
});
|
||||||
|
|
||||||
|
const tenantId = "test-tenant";
|
||||||
|
|
||||||
|
it("should redirect to provider login with tenantId in state", async () => {
|
||||||
|
(redis.get as any).mockImplementation((key: string) => {
|
||||||
|
if (key.includes("config:trakt")) return Promise.resolve(mockProviderConfig);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request(`${AUTH_PREFIX}/trakt/login?tenantId=${tenantId}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
const location = res.headers.get("Location") || "";
|
||||||
|
expect(location).toContain("trakt.tv/oauth/authorize");
|
||||||
|
expect(location).toContain("client_id=trakt-client-id");
|
||||||
|
expect(location).toContain(`state=${encodeURIComponent(`${tenantId}:trakt`)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle callback and exchange code using tenantId from state", async () => {
|
||||||
|
(redis.get as any).mockImplementation((key: string) => {
|
||||||
|
if (key.includes("config:trakt")) return Promise.resolve(mockProviderConfig);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
access_token: "exchange-access-token",
|
||||||
|
refresh_token: "exchange-refresh-token",
|
||||||
|
expires_in: 3600,
|
||||||
|
}),
|
||||||
|
} as any),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request(
|
||||||
|
`${AUTH_PREFIX}/callback?state=${tenantId}:trakt&code=temporary-auth-code`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
expect(res.headers.get("Location")).toBe(`/app/success?provider=trakt&tenantId=${tenantId}`);
|
||||||
|
expect(redis.set).toHaveBeenCalledWith(
|
||||||
|
`tenant:${tenantId}:provider:trakt:access_token`,
|
||||||
|
"exchange-access-token",
|
||||||
|
"EX",
|
||||||
|
3600,
|
||||||
|
);
|
||||||
|
expect(fetchSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 if provider not configured during login", async () => {
|
||||||
|
const res = await app.request(`${AUTH_PREFIX}/unknown-provider/login?tenantId=${tenantId}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 400 if callback is missing state or code", async () => {
|
||||||
|
const res = await app.request(`${AUTH_PREFIX}/callback?code=some-code`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { API_PREFIX } from "../../src/constants";
|
||||||
|
import { redis } from "../../src/core/RedisClient";
|
||||||
|
import { app } from "../../src/index";
|
||||||
|
|
||||||
|
describe("Config Integration", () => {
|
||||||
|
it("should list all configured providers", async () => {
|
||||||
|
(redis.keys as any).mockReturnValue(Promise.resolve(["config:trakt"]));
|
||||||
|
(redis.get as any).mockImplementation(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
JSON.stringify({
|
||||||
|
clientId: "trakt-client-id",
|
||||||
|
clientSecret: "trakt-client-secret",
|
||||||
|
authUrl: "https://trakt.tv/oauth/authorize",
|
||||||
|
tokenUrl: "https://api.trakt.tv/oauth/token",
|
||||||
|
scope: "public",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request(`${API_PREFIX}/config`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.trakt).toBeDefined();
|
||||||
|
expect(body.trakt.clientId).toBe("trakt-client-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set a new provider config", async () => {
|
||||||
|
const newProviderConfig = {
|
||||||
|
clientId: "github-client-id",
|
||||||
|
clientSecret: "github-client-secret",
|
||||||
|
authUrl: "https://github.com/login/oauth/authorize",
|
||||||
|
tokenUrl: "https://github.com/login/oauth/access_token",
|
||||||
|
scope: "user:email",
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await app.request(`${API_PREFIX}/config/github`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(newProviderConfig),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(redis.set).toHaveBeenCalledWith(
|
||||||
|
"config:github",
|
||||||
|
expect.stringContaining("github-client-id"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 400 for invalid config body", async () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
clientId: "missing-other-required-fields",
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await app.request(`${API_PREFIX}/config/invalid`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(invalidConfig),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should delete a provider configuration and clean up all tenant tokens", async () => {
|
||||||
|
(redis.keys as any).mockReturnValue(Promise.resolve(["tenant:1:provider:trakt:token"]));
|
||||||
|
const res = await app.request(`${API_PREFIX}/config/trakt`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(redis.del).toHaveBeenCalledWith("config:trakt");
|
||||||
|
expect(redis.keys).toHaveBeenCalledWith("tenant:*:provider:trakt:*");
|
||||||
|
expect(redis.del).toHaveBeenCalledWith("tenant:1:provider:trakt:token");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { API_PREFIX } from "../../src/constants";
|
||||||
|
import { redis } from "../../src/core/RedisClient";
|
||||||
|
import { app } from "../../src/index";
|
||||||
|
|
||||||
|
describe("Dashboard & Common Integration", () => {
|
||||||
|
it("should serve the dashboard HTML", async () => {
|
||||||
|
const res = await app.request("/app");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toContain("text/html");
|
||||||
|
const html = await res.text();
|
||||||
|
expect(html).toContain("<!DOCTYPE html>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 with custom handler for unknown routes", async () => {
|
||||||
|
const res = await app.request("/unknown-route");
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toBe("Not Found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 500 for internal errors", async () => {
|
||||||
|
(redis.keys as any).mockImplementationOnce(() => {
|
||||||
|
throw new Error("Redis Crash");
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request(`${API_PREFIX}/status`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
"X-Tenant-ID": "test-tenant",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toBe("Internal Server Error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set a cookie on successful unlock", async () => {
|
||||||
|
const res = await app.request("/app/unlock", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ apiKey: "test-api-key" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Set-Cookie")).toContain("toknd_api_key=test-api-key");
|
||||||
|
expect(res.headers.get("Set-Cookie")).toContain("HttpOnly");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear the cookie on logout", async () => {
|
||||||
|
const res = await app.request("/app/logout", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Set-Cookie")).toContain("toknd_api_key=;");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||||
|
import { GenericProvider } from "../../src/providers/GenericProvider";
|
||||||
|
|
||||||
|
describe("GenericProvider", () => {
|
||||||
|
const traktConfig = {
|
||||||
|
clientId: "trakt-client-id",
|
||||||
|
clientSecret: "trakt-client-secret",
|
||||||
|
authUrl: "https://trakt.tv/oauth/authorize",
|
||||||
|
tokenUrl: "https://api.trakt.tv/oauth/token",
|
||||||
|
scope: "public",
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate correct auth URL", () => {
|
||||||
|
const provider = new GenericProvider("trakt", traktConfig);
|
||||||
|
|
||||||
|
const url = provider.getAuthUrl("random-state-123", "https://callback.com");
|
||||||
|
|
||||||
|
expect(url).toContain("client_id=trakt-client-id");
|
||||||
|
expect(url).toContain("redirect_uri=https%3A%2F%2Fcallback.com");
|
||||||
|
expect(url).toContain("state=random-state-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle token response with string expiry", async () => {
|
||||||
|
const provider = new GenericProvider("trakt", traktConfig);
|
||||||
|
const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
access_token: "new-access-token",
|
||||||
|
refresh_token: "new-refresh-token",
|
||||||
|
expires_in: "7200",
|
||||||
|
}),
|
||||||
|
text: () => Promise.resolve(""),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokens = await provider.refreshToken("old-refresh-token");
|
||||||
|
|
||||||
|
expect(tokens.accessToken).toBe("new-access-token");
|
||||||
|
expect(tokens.expiresIn).toBe(7200);
|
||||||
|
expect(fetchSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle token response without new refresh token", async () => {
|
||||||
|
const provider = new GenericProvider("trakt", traktConfig);
|
||||||
|
spyOn(globalThis, "fetch").mockImplementation(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
access_token: "new-access-token-only",
|
||||||
|
expires_in: 3600,
|
||||||
|
}),
|
||||||
|
text: () => Promise.resolve(""),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokens = await provider.refreshToken("existing-refresh-token");
|
||||||
|
|
||||||
|
expect(tokens.accessToken).toBe("new-access-token-only");
|
||||||
|
expect(tokens.refreshToken).toBe("existing-refresh-token");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
process.env.API_KEY = "test-api-key";
|
||||||
|
process.env.REDIS_HOST = "localhost";
|
||||||
|
process.env.REDIS_PORT = "6379";
|
||||||
|
process.env.APP_PORT = "3000";
|
||||||
|
|
||||||
|
import { afterEach, mock } from "bun:test";
|
||||||
|
|
||||||
|
// Global config mock
|
||||||
|
mock.module("../src/config", () => ({
|
||||||
|
config: {
|
||||||
|
API_KEY: "test-api-key",
|
||||||
|
REDIS_HOST: "localhost",
|
||||||
|
REDIS_PORT: 6379,
|
||||||
|
APP_PORT: "3000",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Global Redis mock
|
||||||
|
mock.module("../src/core/RedisClient", () => ({
|
||||||
|
redis: {
|
||||||
|
status: "ready",
|
||||||
|
get: mock(() => Promise.resolve(null)),
|
||||||
|
set: mock(() => Promise.resolve()),
|
||||||
|
del: mock(() => Promise.resolve(1)),
|
||||||
|
keys: mock(() => Promise.resolve([])),
|
||||||
|
on: mock(() => {}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
const { redis } = await import("../src/core/RedisClient");
|
||||||
|
mock.restore();
|
||||||
|
redis.get.mockImplementation(() => Promise.resolve(null));
|
||||||
|
redis.set.mockImplementation(() => Promise.resolve());
|
||||||
|
redis.del.mockImplementation(() => Promise.resolve(1));
|
||||||
|
redis.keys.mockImplementation(() => Promise.resolve([]));
|
||||||
|
});
|
||||||
@@ -5,8 +5,10 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx",
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext"],
|
||||||
"types": ["node"]
|
"types": ["node", "bun-types"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*", "tests/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||