Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b8ce54231 | |||
| 1cf6e3e4d3 | |||
| 708a4abba7 | |||
| 93d01bc39d | |||
| 8c145ae274 | |||
| 332ee26de2 | |||
| 5b72ded2d5 | |||
| 4e55c1e4aa | |||
| 3900073086 | |||
| 7ac46e1d2e | |||
| 37d4f37206 | |||
| eb7a544a0c | |||
| 2272fac26f | |||
| 96e132c2fb | |||
| f85b6301dd | |||
| d096627b28 | |||
| 672ca10ffe |
@@ -6,17 +6,18 @@ Building integrations is fun until you have to manage the tokens. Suddenly, you'
|
|||||||
|
|
||||||
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.
|
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. 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.
|
**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.
|
You just need to authenticate once. toknd manages the lifecycle of the tokens forever, securely isolated by tenant.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Why does this exist?
|
## 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" capabilities required for modern AI and microservice architectures—without the bloat, vendor lock-in, or SaaS costs.
|
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
|
## 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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
@@ -24,6 +25,7 @@ There are massive enterprise identity brokers (like Auth0 or Dex), and then ther
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
- **Native Multi-Tenancy:** Isolate tokens for unlimited users/tenants using a single instance.
|
||||||
- **Drop-in Infrastructure:** Deploys in seconds via Docker (or Podman) Compose or just a simple Bun script.
|
- **Drop-in Infrastructure:** Deploys in seconds via Docker (or Podman) Compose or just a simple Bun script.
|
||||||
- **Centralized Provider Management:** Native support for managing multiple OAuth2 providers (Google, GitHub, Trakt, etc.).
|
- **Centralized Provider Management:** Native support for managing multiple OAuth2 providers (Google, GitHub, Trakt, etc.).
|
||||||
- **API Key Security:** Isolated and secure access to the broker via master API keys. Each instance can use its own key for isolation.
|
- **API Key Security:** Isolated and secure access to the broker via master API keys. Each instance can use its own key for isolation.
|
||||||
@@ -89,12 +91,21 @@ toknd provides a built-in **Scalar API Reference** so you can explore and test e
|
|||||||
- **Interactive UI**: [http://localhost:3000/api](http://localhost:3000/api) (or `/docs`)
|
- **Interactive UI**: [http://localhost:3000/api](http://localhost:3000/api) (or `/docs`)
|
||||||
- **OpenAPI Spec**: [http://localhost:3000/doc](http://localhost:3000/doc)
|
- **OpenAPI Spec**: [http://localhost:3000/doc](http://localhost:3000/doc)
|
||||||
|
|
||||||
### The Golden Rule
|
### Multi-Tenancy & The Golden Rule
|
||||||
All protected endpoints require your master API key in the Authorization header:
|
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
|
```http
|
||||||
Authorization: Bearer <your_master_api_key>
|
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
|
### The Dashboard
|
||||||
REST API too complicated to use? No problem!
|
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:
|
You absolutely don't have to manage everything via curl. Access the web dashboard to configure providers, trigger manual refreshes, and monitor token health:
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ export class ConfigManager {
|
|||||||
|
|
||||||
async deleteProviderConfig(provider: string): Promise<void> {
|
async deleteProviderConfig(provider: string): Promise<void> {
|
||||||
await this.redis.del(`config:${provider}`);
|
await this.redis.del(`config:${provider}`);
|
||||||
// Also clean up tokens
|
// Also clean up tokens across all tenants
|
||||||
const tokenKeys = await this.redis.keys(`provider:${provider}:*`);
|
const tokenKeys = await this.redis.keys(`tenant:*:provider:${provider}:*`);
|
||||||
if (tokenKeys.length > 0) {
|
if (tokenKeys.length > 0) {
|
||||||
await this.redis.del(...tokenKeys);
|
await this.redis.del(...tokenKeys);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-17
@@ -7,41 +7,37 @@ 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,
|
|
||||||
"EX",
|
|
||||||
tokens.expiresIn,
|
|
||||||
);
|
|
||||||
await this.redis.set(`provider:${providerName}:refresh_token`, tokens.refreshToken);
|
|
||||||
await this.redis.set(`provider:${providerName}:last_updated`, new Date().toISOString());
|
|
||||||
await this.redis.set(
|
|
||||||
`provider:${providerName}:expires_at`,
|
|
||||||
new Date(Date.now() + tokens.expiresIn * 1000).toISOString(),
|
new Date(Date.now() + tokens.expiresIn * 1000).toISOString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,7 @@ import { prettyJSON } from "hono/pretty-json";
|
|||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
import { API_PREFIX, AUTH_PREFIX, DOCS_PREFIX } from "./constants";
|
import { API_PREFIX, AUTH_PREFIX, DOCS_PREFIX } from "./constants";
|
||||||
import { redis } from "./core/RedisClient";
|
import { redis } from "./core/RedisClient";
|
||||||
import { openApiSpec, securityScheme } from "./openapi";
|
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";
|
||||||
@@ -17,6 +17,7 @@ const app = new OpenAPIHono({ strict: false });
|
|||||||
// OpenAPI specs
|
// OpenAPI specs
|
||||||
app.doc(`${DOCS_PREFIX}/openapi.json`, openApiSpec);
|
app.doc(`${DOCS_PREFIX}/openapi.json`, openApiSpec);
|
||||||
app.openAPIRegistry.registerComponent("securitySchemes", "API_KEY", securityScheme);
|
app.openAPIRegistry.registerComponent("securitySchemes", "API_KEY", securityScheme);
|
||||||
|
app.openAPIRegistry.registerComponent("securitySchemes", "TENANT_ID", tenantIdScheme);
|
||||||
|
|
||||||
// Scalar API Reference
|
// Scalar API Reference
|
||||||
app.get(
|
app.get(
|
||||||
|
|||||||
+9
-2
@@ -6,7 +6,7 @@ export const openApiSpec = {
|
|||||||
version: `${API_VERSION}.${APP_VERSION}`,
|
version: `${API_VERSION}.${APP_VERSION}`,
|
||||||
title: "toknd Auth Broker API",
|
title: "toknd Auth Broker API",
|
||||||
description:
|
description:
|
||||||
"A high-performance OAuth2 broker and token management service. Designed to centralize provider configurations and automate token lifecycle management across distributed systems.",
|
"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: [
|
tags: [
|
||||||
{
|
{
|
||||||
@@ -22,10 +22,17 @@ export const openApiSpec = {
|
|||||||
description: "System-level OAuth2 handshake and callback processing.",
|
description: "System-level OAuth2 handshake and callback processing.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
security: [{ API_KEY: [] }],
|
security: [{ API_KEY: [], TENANT_ID: [] }],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const securityScheme = {
|
export const securityScheme = {
|
||||||
type: "http",
|
type: "http",
|
||||||
scheme: "bearer",
|
scheme: "bearer",
|
||||||
} as const;
|
} 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;
|
||||||
|
|||||||
+29
-13
@@ -54,8 +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"],
|
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 } },
|
||||||
@@ -67,12 +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"],
|
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: {
|
||||||
@@ -89,12 +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"],
|
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: {
|
||||||
@@ -112,15 +123,17 @@ 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`);
|
||||||
const expiresAt = await redis.get(`provider:${provider}:expires_at`);
|
const lastUpdated = await redis.get(`${baseKey}:last_updated`);
|
||||||
|
const expiresAt = await redis.get(`${baseKey}:expires_at`);
|
||||||
status[provider] = { accessToken, refreshToken, lastUpdated, expiresAt };
|
status[provider] = { accessToken, refreshToken, lastUpdated, expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,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);
|
||||||
|
|
||||||
@@ -139,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);
|
||||||
}
|
}
|
||||||
@@ -148,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);
|
||||||
|
|
||||||
@@ -158,11 +173,12 @@ 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 expiresAt = await redis.get(`provider:${providerName}:expires_at`);
|
const lastUpdated = await redis.get(`${baseKey}:last_updated`);
|
||||||
|
const expiresAt = await redis.get(`${baseKey}:expires_at`);
|
||||||
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
|
|||||||
+27
-10
@@ -23,6 +23,9 @@ const loginRoute = createRoute({
|
|||||||
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: {
|
||||||
@@ -42,16 +45,13 @@ const callbackRoute = createRoute({
|
|||||||
summary: "OAuth2 callback handler (Managed by System)",
|
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 } },
|
||||||
@@ -71,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);
|
||||||
|
|
||||||
@@ -88,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);
|
||||||
@@ -115,9 +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);
|
||||||
|
|
||||||
return c.redirect(`/app/success?provider=${providerName}`);
|
return c.redirect(`/app/success?provider=${providerName}&tenantId=${tenantId}`);
|
||||||
} 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}`);
|
||||||
|
|||||||
+494
-437
@@ -63,498 +63,555 @@ export const Layout = (props: { title: string; children: Child; isUnlocked?: boo
|
|||||||
|
|
||||||
export const Dashboard = (props: { isUnlocked: boolean }) => (
|
export const Dashboard = (props: { isUnlocked: boolean }) => (
|
||||||
<Layout title="toknd — Auth Broker Dashboard" isUnlocked={props.isUnlocked}>
|
<Layout title="toknd — Auth Broker Dashboard" isUnlocked={props.isUnlocked}>
|
||||||
<div class="navbar bg-base-100 shadow-sm px-4 md:px-8 border-b border-base-300">
|
<div class="flex flex-col min-h-screen">
|
||||||
<div class="flex-1 flex items-center gap-6">
|
<div class="navbar bg-base-100 shadow-sm px-4 md:px-8 border-b border-base-300 relative z-50">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex-1 flex items-center gap-6">
|
||||||
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-primary-content font-semibold text-lg">
|
<div class="flex items-center gap-2">
|
||||||
<i class="ph-duotone ph-fingerprint"></i>
|
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-primary-content font-semibold text-lg">
|
||||||
</div>
|
<i class="ph-duotone ph-fingerprint"></i>
|
||||||
<div class="text-xl font-semibold tracking-tight">
|
</div>
|
||||||
toknd <span class="text-xs font-normal opacity-50 ml-1">auth broker</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<nav class="hidden md:flex items-center gap-1">
|
<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
|
<a
|
||||||
href={DOCS_PREFIX}
|
href={DOCS_PREFIX}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="btn btn-ghost btn-sm text-base-content/60 hover:text-primary gap-2 px-3"
|
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"
|
rel="noopener"
|
||||||
>
|
>
|
||||||
<i class="ph-duotone ph-book-open text-lg"></i>
|
<i class="ph-duotone ph-book-open text-lg"></i>
|
||||||
<span class="font-bold uppercase tracking-widest text-xs">
|
<span class="font-bold uppercase tracking-widest">
|
||||||
API Reference{" "}
|
API Reference
|
||||||
<sup class="text-xxs opacity-50 ml-0.5">
|
<sup class="text-xxs opacity-50 ml-0.5">
|
||||||
{API_VERSION}.{APP_VERSION}
|
{API_VERSION}.{APP_VERSION}
|
||||||
</sup>
|
</sup>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="flex-none hidden sm:flex">
|
|
||||||
<template x-if="!isUnlocked">
|
|
||||||
<div class="join border border-base-200/50 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-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-48 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()"
|
|
||||||
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 md:inline"
|
|
||||||
x-text="loading ? 'Unlocking...' : 'Unlock'"
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="isUnlocked">
|
<div class="divider my-0 opacity-50 lg:hidden"></div>
|
||||||
<button
|
|
||||||
x-on:click="logout()"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-sm text-error hover:bg-error/10 gap-2 px-4"
|
|
||||||
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>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container mx-auto p-4 md:p-8 max-w-7xl">
|
<template x-if="!isUnlocked">
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 md:gap-8">
|
<div class="flex flex-col lg:flex-row gap-4 items-start lg:items-center w-full lg:w-auto">
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-4 self-start">
|
<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="card-body p-6">
|
<div class="join-item flex items-center px-4 bg-base-200">
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<i class="ph-duotone ph-key text-secondary text-lg"></i>
|
||||||
<i class="ph-duotone ph-shield-plus text-2xl text-primary mb-1"></i>
|
</div>
|
||||||
<div class="w-1 h-6 bg-primary/50 rounded-full"></div>
|
<div class="relative flex-1" x-data="{ show: false }">
|
||||||
<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
|
<input
|
||||||
x-bind:type="show ? 'text' : 'password'"
|
x-bind:type="show ? 'text' : 'password'"
|
||||||
id="clientSecret"
|
id="apiKey"
|
||||||
x-model="form.clientSecret"
|
name="apiKey"
|
||||||
placeholder="OAuth client secret"
|
x-model="apiKey"
|
||||||
required
|
aria-label="Master API Key"
|
||||||
class="input input-bordered w-full focus:input-primary pr-12"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
x-on:click="show = !show"
|
x-on:click="show = !show"
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square"
|
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-lg opacity-40' : 'ph-duotone ph-eye text-lg opacity-40'"></i>
|
<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>
|
||||||
</div>
|
</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
|
<button
|
||||||
|
x-on:click="unlock(); document.getElementById('mobile-menu').checked = false;"
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary w-full shadow-md"
|
class="btn btn-primary btn-sm join-item px-6"
|
||||||
x-bind:disabled="loading"
|
x-bind:disabled="loading"
|
||||||
>
|
>
|
||||||
<i class="ph ph-plus-bold mr-1"></i>
|
<i class="ph-duotone ph-lock-key-open text-lg" x-show="!loading"></i>
|
||||||
Save Configuration
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-8 overflow-hidden">
|
<template x-if="isUnlocked">
|
||||||
<div class="card-body p-0">
|
<div class="flex flex-col lg:flex-row items-start lg:items-center gap-4 w-full lg:w-auto">
|
||||||
<div class="p-6 pb-4 flex justify-between items-center bg-base-100">
|
<form
|
||||||
<div class="flex items-center gap-2">
|
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"
|
||||||
<i class="ph-duotone ph-shipping-container text-2xl text-primary"></i>
|
x-on:submit="$event.preventDefault(); if(isTenantLocked) { isTenantLocked = false; } else { isTenantLocked = true; fetchProviders(); document.getElementById('mobile-menu').checked = false; }"
|
||||||
<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>
|
<div class="join-item flex items-center px-4 bg-base-300 opacity-70">
|
||||||
Refresh List
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50 z-40 hidden peer-checked:block lg:hidden"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="relative min-h-[400px]">
|
<div class="container mx-auto p-4 md:p-8 max-w-7xl">
|
||||||
<div
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 md:gap-8">
|
||||||
x-show="loading && providers.length > 0"
|
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-4 self-start">
|
||||||
class="absolute inset-0 bg-base-100/50 backdrop-blur-md z-10 flex items-center justify-center"
|
<div class="card-body p-6">
|
||||||
>
|
<div class="flex items-center gap-2 mb-4">
|
||||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div x-show="!isUnlocked" class="p-20 text-center opacity-30">
|
<form x-on:submit="saveConfig" class="space-y-4">
|
||||||
<div class="flex flex-col items-center gap-3">
|
<div class="form-control">
|
||||||
<i class="ph ph-lock-key text-6xl"></i>
|
<label htmlFor="providerName" class="label py-1">
|
||||||
<p class="font-medium">Enter Master API Key to access registry</p>
|
<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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest">
|
||||||
x-show="isUnlocked && providers.length === 0 && !loading"
|
Credentials
|
||||||
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 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>
|
||||||
|
|
||||||
<div
|
<div class="relative min-h-[400px]">
|
||||||
x-show="isUnlocked && providers.length > 0"
|
<div
|
||||||
class="p-6 grid grid-cols-1 md:grid-cols-2 gap-4"
|
x-show="loading && providers.length > 0"
|
||||||
>
|
class="absolute inset-0 bg-base-100/50 backdrop-blur-md z-10 flex items-center justify-center"
|
||||||
<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">
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
<div class="card-body p-5">
|
</div>
|
||||||
<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-show="!isUnlocked" class="p-20 text-center opacity-30">
|
||||||
<div x-data="{ show: false }">
|
<div class="flex flex-col items-center gap-3">
|
||||||
<div class="text-xs uppercase font-semibold opacity-30 block mb-1">
|
<i class="ph ph-lock-key text-6xl"></i>
|
||||||
Access Token
|
<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>
|
</div>
|
||||||
<div
|
<span
|
||||||
x-show="provider.status.accessToken"
|
x-text="provider.config.clientId"
|
||||||
class="flex items-center gap-2 bg-base-100 rounded border border-base-300 p-1 pl-3"
|
x-bind:title="provider.config.clientId"
|
||||||
>
|
class="text-xs opacity-40 truncate font-mono"
|
||||||
<input
|
></span>
|
||||||
x-bind:type="show ? 'text' : 'password'"
|
</div>
|
||||||
x-bind:value="provider.status.accessToken"
|
|
||||||
readonly
|
<div class="space-y-4">
|
||||||
class="bg-transparent border-none outline-none shadow-none focus:ring-0 text-xs flex-1 min-w-0 font-mono"
|
<div x-data="{ show: false }">
|
||||||
/>
|
<div class="text-xs uppercase font-semibold opacity-30 block mb-1">
|
||||||
<div class="flex gap-1">
|
Access Token
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<div
|
||||||
x-on:click="show = !show"
|
x-show="provider.status.accessToken"
|
||||||
class="btn btn-ghost btn-xs btn-square"
|
class="flex items-center gap-2 bg-base-100 rounded border border-base-300 p-1 pl-3"
|
||||||
>
|
>
|
||||||
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-base opacity-50' : 'ph-duotone ph-eye text-base opacity-50'"></i>
|
<input
|
||||||
</button>
|
x-bind:type="show ? 'text' : 'password'"
|
||||||
<button
|
x-bind:value="provider.status.accessToken"
|
||||||
type="button"
|
readonly
|
||||||
x-on:click="copyToClipboard(provider.status.accessToken)"
|
class="bg-transparent border-none outline-none shadow-none focus:ring-0 text-xs flex-1 min-w-0 font-mono"
|
||||||
class="btn btn-ghost btn-xs btn-square"
|
/>
|
||||||
>
|
<div class="flex gap-1">
|
||||||
<i class="ph-duotone ph-copy-simple text-base opacity-50"></i>
|
<button
|
||||||
</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>
|
</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 x-data="{ show: false }">
|
||||||
<div class="text-xs uppercase font-semibold opacity-30 block mb-1">
|
<div class="text-xs uppercase font-semibold opacity-30 block mb-1">
|
||||||
Refresh Token
|
Refresh Token
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
x-show="provider.status.refreshToken"
|
x-show="provider.status.refreshToken"
|
||||||
class="flex items-center gap-2 bg-base-100 rounded border border-base-300 p-1 pl-3"
|
class="flex items-center gap-2 bg-base-100 rounded border border-base-300 p-1 pl-3"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
x-bind:type="show ? 'text' : 'password'"
|
x-bind:type="show ? 'text' : 'password'"
|
||||||
x-bind:value="provider.status.refreshToken"
|
x-bind:value="provider.status.refreshToken"
|
||||||
readonly
|
readonly
|
||||||
class="bg-transparent border-none outline-none shadow-none focus:ring-0 text-xs flex-1 min-w-0 font-mono"
|
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">
|
<div class="flex gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
x-on:click="show = !show"
|
x-on:click="show = !show"
|
||||||
class="btn btn-ghost btn-xs btn-square"
|
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>
|
<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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
x-on:click="copyToClipboard(provider.status.refreshToken)"
|
x-on:click="copyToClipboard(provider.status.refreshToken)"
|
||||||
class="btn btn-ghost btn-xs btn-square"
|
class="btn btn-ghost btn-xs btn-square"
|
||||||
>
|
>
|
||||||
<i class="ph-duotone ph-copy-simple text-base opacity-50"></i>
|
<i class="ph-duotone ph-copy-simple text-base opacity-50"></i>
|
||||||
</button>
|
</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>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="divider my-3 opacity-10"></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
|
||||||
<div class="p-1 flex flex-col gap-0.5">
|
x-show="provider.status.accessToken"
|
||||||
<span class="text-xxs font-bold opacity-20 uppercase tracking-wide">
|
class="grid grid-cols-2 gap-4 mb-5"
|
||||||
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', '_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>
|
<div class="p-1 flex flex-col gap-0.5">
|
||||||
<span class="text-xs uppercase font-bold tracking-widest">
|
<span class="text-xxs font-bold opacity-20 uppercase tracking-wide">
|
||||||
Refresh Tokens
|
Last Updated
|
||||||
</span>
|
</span>
|
||||||
</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
export const API_VERSION = "v1";
|
export const API_VERSION = "v1";
|
||||||
export const APP_VERSION = "1.0";
|
export const APP_VERSION = "1.1";
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
"dashboard",
|
"dashboard",
|
||||||
({ initialIsUnlocked, apiPrefix, authPrefix, docsPrefix, apiVersion, appVersion }) => ({
|
({ initialIsUnlocked, apiPrefix, authPrefix, docsPrefix, apiVersion, appVersion }) => ({
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
|
tenantId: localStorage.getItem("toknd_tenant_id") || "default",
|
||||||
|
isTenantLocked: !!localStorage.getItem("toknd_tenant_id"),
|
||||||
isUnlocked: initialIsUnlocked,
|
isUnlocked: initialIsUnlocked,
|
||||||
apiPrefix,
|
apiPrefix,
|
||||||
authPrefix,
|
authPrefix,
|
||||||
@@ -57,6 +59,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
this.$watch("tenantId", (val) => {
|
||||||
|
localStorage.setItem("toknd_tenant_id", val);
|
||||||
|
});
|
||||||
if (this.isUnlocked) {
|
if (this.isUnlocked) {
|
||||||
this.fetchProviders();
|
this.fetchProviders();
|
||||||
}
|
}
|
||||||
@@ -104,7 +109,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
try {
|
try {
|
||||||
const [configRes, statusRes] = await Promise.all([
|
const [configRes, statusRes] = await Promise.all([
|
||||||
fetch(`${this.apiPrefix}/config`),
|
fetch(`${this.apiPrefix}/config`),
|
||||||
fetch(`${this.apiPrefix}/status`),
|
fetch(`${this.apiPrefix}/status`, {
|
||||||
|
headers: { "x-tenant-id": this.tenantId },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (configRes.status === 401 || statusRes.status === 401) {
|
if (configRes.status === 401 || statusRes.status === 401) {
|
||||||
@@ -180,6 +187,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${this.apiPrefix}/refresh/${name}`, {
|
const res = await fetch(`${this.apiPrefix}/refresh/${name}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: { "x-tenant-id": this.tenantId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { describe, expect, it, mock } from "bun:test";
|
import { describe, expect, it, mock } from "bun:test";
|
||||||
import { ConfigManager } from "../../src/core/ConfigManager";
|
import { ConfigManager } from "../../src/core/ConfigManager";
|
||||||
|
|
||||||
describe("ConfigManager", () => {
|
describe("ConfigManager", () => {
|
||||||
it("should save and retrieve provider configuration", async () => {
|
it("should save and retrieve provider configuration", async () => {
|
||||||
const storage = {};
|
const storage: Record<string, string> = {};
|
||||||
const redis = {
|
const redis = {
|
||||||
set: mock((key, val) => {
|
set: mock((key: string, val: string) => {
|
||||||
storage[key] = val;
|
storage[key] = val;
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}),
|
}),
|
||||||
get: mock((key) => Promise.resolve(storage[key] || null)),
|
get: mock((key: string) => Promise.resolve(storage[key] || null)),
|
||||||
};
|
};
|
||||||
const manager = new ConfigManager(redis);
|
const manager = new ConfigManager(redis as any);
|
||||||
const traktConfig = {
|
const traktConfig = {
|
||||||
clientId: "trakt-client-id",
|
clientId: "trakt-client-id",
|
||||||
clientSecret: "trakt-client-secret",
|
clientSecret: "trakt-client-secret",
|
||||||
@@ -24,14 +23,14 @@ describe("ConfigManager", () => {
|
|||||||
await manager.setProviderConfig("trakt", traktConfig);
|
await manager.setProviderConfig("trakt", traktConfig);
|
||||||
const retrieved = await manager.getProviderConfig("trakt");
|
const retrieved = await manager.getProviderConfig("trakt");
|
||||||
|
|
||||||
expect(retrieved).toEqual(traktConfig);
|
expect(retrieved).toEqual(traktConfig as any);
|
||||||
expect(redis.set).toHaveBeenCalled();
|
expect(redis.set).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return all providers", async () => {
|
it("should return all providers", async () => {
|
||||||
const redis = {
|
const redis = {
|
||||||
keys: mock(() => Promise.resolve(["config:trakt", "config:github"])),
|
keys: mock(() => Promise.resolve(["config:trakt", "config:github"])),
|
||||||
get: mock((key) =>
|
get: mock((key: string) =>
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
clientId: `${key}-id`,
|
clientId: `${key}-id`,
|
||||||
@@ -43,7 +42,7 @@ describe("ConfigManager", () => {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
const manager = new ConfigManager(redis);
|
const manager = new ConfigManager(redis as any);
|
||||||
|
|
||||||
const providers = await manager.getAllProviders();
|
const providers = await manager.getAllProviders();
|
||||||
|
|
||||||
@@ -53,10 +52,32 @@ describe("ConfigManager", () => {
|
|||||||
|
|
||||||
it("should return null for non-existent provider", async () => {
|
it("should return null for non-existent provider", async () => {
|
||||||
const redis = { get: mock(() => Promise.resolve(null)) };
|
const redis = { get: mock(() => Promise.resolve(null)) };
|
||||||
const manager = new ConfigManager(redis);
|
const manager = new ConfigManager(redis as any);
|
||||||
|
|
||||||
const config = await manager.getProviderConfig("missing-provider");
|
const config = await manager.getProviderConfig("missing-provider");
|
||||||
|
|
||||||
expect(config).toBeNull();
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { describe, expect, it, mock } from "bun:test";
|
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 redis = { get: mock(() => Promise.resolve("active-access-token")) };
|
const redis = { get: mock(() => Promise.resolve("active-access-token")) };
|
||||||
const manager = new TokenManager(redis, {});
|
const manager = new TokenManager(redis as any, {} as any);
|
||||||
|
|
||||||
const token = await manager.getAccessToken("trakt");
|
const token = await manager.getAccessToken(tenantId, "trakt");
|
||||||
|
|
||||||
expect(token).toBe("active-access-token");
|
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 () => {
|
||||||
@@ -26,19 +28,26 @@ describe("TokenManager", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
const manager = new TokenManager(redis, provider);
|
const manager = new TokenManager(redis as any, provider as any);
|
||||||
|
|
||||||
const token = await manager.getAccessToken("trakt");
|
const token = await manager.getAccessToken(tenantId, "trakt");
|
||||||
|
|
||||||
expect(token).toBe("newly-refreshed-access-token");
|
expect(token).toBe("newly-refreshed-access-token");
|
||||||
expect(redis.set).toHaveBeenCalled();
|
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 redis = { get: mock(() => Promise.resolve(null)) };
|
const redis = { get: mock(() => Promise.resolve(null)) };
|
||||||
const manager = new TokenManager(redis, {});
|
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();
|
||||||
});
|
});
|
||||||
@@ -57,19 +66,26 @@ describe("TokenManager", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
const manager = new TokenManager(redis, provider);
|
const manager = new TokenManager(redis as any, provider as any);
|
||||||
|
|
||||||
const token = await manager.refreshAccessToken("trakt");
|
const token = await manager.refreshAccessToken(tenantId, "trakt");
|
||||||
|
|
||||||
expect(token).toBe("manually-refreshed-access-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(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 refreshAccessToken if no refresh token is found", async () => {
|
it("should return null in refreshAccessToken if no refresh token is found", async () => {
|
||||||
const redis = { get: mock(() => Promise.resolve(null)) };
|
const redis = { get: mock(() => Promise.resolve(null)) };
|
||||||
const manager = new TokenManager(redis, {});
|
const manager = new TokenManager(redis as any, {} as any);
|
||||||
|
|
||||||
const token = await manager.refreshAccessToken("trakt");
|
const token = await manager.refreshAccessToken(tenantId, "trakt");
|
||||||
|
|
||||||
expect(token).toBeNull();
|
expect(token).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { describe, expect, it, spyOn } from "bun:test";
|
import { describe, expect, it, spyOn } from "bun:test";
|
||||||
import { API_PREFIX } from "../../src/constants";
|
import { API_PREFIX } from "../../src/constants";
|
||||||
import { redis } from "../../src/core/RedisClient";
|
import { redis } from "../../src/core/RedisClient";
|
||||||
@@ -13,6 +12,8 @@ describe("API Integration", () => {
|
|||||||
scope: "public",
|
scope: "public",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tenantId = "test-tenant";
|
||||||
|
|
||||||
it("should return 401 if API Key is missing", async () => {
|
it("should return 401 if API Key is missing", async () => {
|
||||||
const res = await app.request(`${API_PREFIX}/status`);
|
const res = await app.request(`${API_PREFIX}/status`);
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ describe("API Integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should return 503 for health check if redis is down", async () => {
|
it("should return 503 for health check if redis is down", async () => {
|
||||||
redis.status = "connecting";
|
(redis as any).status = "connecting";
|
||||||
|
|
||||||
const res = await app.request("/health");
|
const res = await app.request("/health");
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
@@ -40,18 +41,31 @@ describe("API Integration", () => {
|
|||||||
expect(body.redis).toBe("connecting");
|
expect(body.redis).toBe("connecting");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return 200 for status with valid API Key", async () => {
|
it("should return 400 if X-Tenant-ID is missing", async () => {
|
||||||
redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
|
const res = await app.request(`${API_PREFIX}/status`, {
|
||||||
redis.get.mockImplementation((key) => {
|
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("config")) return Promise.resolve(mockTraktConfig);
|
||||||
if (key.includes("access_token")) return Promise.resolve("current-access-token");
|
if (key.includes(`tenant:${tenantId}:provider:trakt:access_token`))
|
||||||
if (key.includes("refresh_token")) return Promise.resolve("current-refresh-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);
|
return Promise.resolve(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request(`${API_PREFIX}/status`, {
|
const res = await app.request(`${API_PREFIX}/status`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
|
"X-Tenant-ID": tenantId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,76 +73,35 @@ describe("API Integration", () => {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.trakt).toBeDefined();
|
expect(body.trakt).toBeDefined();
|
||||||
expect(body.trakt.accessToken).toBe("current-access-token");
|
expect(body.trakt.accessToken).toBe("current-access-token");
|
||||||
|
expect(redis.get).toHaveBeenCalledWith(`tenant:${tenantId}:provider:trakt:access_token`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return 200 for status with valid Cookie", async () => {
|
it("should return token for a configured provider with X-Tenant-ID", async () => {
|
||||||
redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
|
(redis.get as any).mockImplementation((key) => {
|
||||||
redis.get.mockImplementation((key) => {
|
|
||||||
if (key.includes("config")) return Promise.resolve(mockTraktConfig);
|
|
||||||
if (key.includes("access_token")) return Promise.resolve("current-access-token");
|
|
||||||
return Promise.resolve(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await app.request(`${API_PREFIX}/status`, {
|
|
||||||
headers: {
|
|
||||||
Cookie: "toknd_api_key=test-api-key",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.trakt).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return 404 for unknown provider token", async () => {
|
|
||||||
const res = await app.request(`${API_PREFIX}/token/unconfigured-provider`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: "Bearer test-api-key",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return token for a configured provider", async () => {
|
|
||||||
redis.get.mockImplementation((key) => {
|
|
||||||
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
|
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
|
||||||
if (key.includes("access_token")) return Promise.resolve("trakt-active-token");
|
if (key.includes(`tenant:${tenantId}:provider:trakt:access_token`))
|
||||||
|
return Promise.resolve("trakt-active-token");
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request(`${API_PREFIX}/token/trakt`, {
|
const res = await app.request(`${API_PREFIX}/token/trakt`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
|
"X-Tenant-ID": tenantId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.access_token).toBe("trakt-active-token");
|
expect(body.access_token).toBe("trakt-active-token");
|
||||||
|
expect(redis.get).toHaveBeenCalledWith(`tenant:${tenantId}:provider:trakt:access_token`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return 404 if no access token is in redis for a valid provider", async () => {
|
it("should successfully refresh a token with X-Tenant-ID", async () => {
|
||||||
redis.get.mockImplementation((key) => {
|
(redis.get as any).mockImplementation((key) => {
|
||||||
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
|
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
|
||||||
return Promise.resolve(null);
|
if (key.includes(`tenant:${tenantId}:provider:trakt:refresh_token`))
|
||||||
});
|
return Promise.resolve("old-refresh-token");
|
||||||
|
|
||||||
const res = await app.request(`${API_PREFIX}/token/trakt`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: "Bearer test-api-key",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.error).toContain("No tokens found");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should successfully refresh a token", async () => {
|
|
||||||
redis.get.mockImplementation((key) => {
|
|
||||||
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
|
|
||||||
if (key.includes("refresh_token")) return Promise.resolve("old-refresh-token");
|
|
||||||
return Promise.resolve("new-access-token-from-refresh");
|
return Promise.resolve("new-access-token-from-refresh");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,13 +114,14 @@ describe("API Integration", () => {
|
|||||||
refresh_token: "new-refresh-token-from-fetch",
|
refresh_token: "new-refresh-token-from-fetch",
|
||||||
expires_in: 3600,
|
expires_in: 3600,
|
||||||
}),
|
}),
|
||||||
}),
|
} as any),
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await app.request(`${API_PREFIX}/refresh/trakt`, {
|
const res = await app.request(`${API_PREFIX}/refresh/trakt`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
|
"X-Tenant-ID": tenantId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,5 +129,6 @@ describe("API Integration", () => {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.success).toBe(true);
|
expect(body.success).toBe(true);
|
||||||
expect(body.status.accessToken).toBe("new-access-token-from-refresh");
|
expect(body.status.accessToken).toBe("new-access-token-from-refresh");
|
||||||
|
expect(redis.get).toHaveBeenCalledWith(`tenant:${tenantId}:provider:trakt:refresh_token`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { describe, expect, it, spyOn } from "bun:test";
|
import { describe, expect, it, spyOn } from "bun:test";
|
||||||
import { AUTH_PREFIX } from "../../src/constants";
|
import { AUTH_PREFIX } from "../../src/constants";
|
||||||
import { redis } from "../../src/core/RedisClient";
|
import { redis } from "../../src/core/RedisClient";
|
||||||
@@ -13,21 +12,25 @@ describe("Auth Integration", () => {
|
|||||||
scope: "public",
|
scope: "public",
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should redirect to provider login", async () => {
|
const tenantId = "test-tenant";
|
||||||
redis.get.mockImplementation((key) => {
|
|
||||||
|
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);
|
if (key.includes("config:trakt")) return Promise.resolve(mockProviderConfig);
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request(`${AUTH_PREFIX}/trakt/login`);
|
const res = await app.request(`${AUTH_PREFIX}/trakt/login?tenantId=${tenantId}`);
|
||||||
|
|
||||||
expect(res.status).toBe(302);
|
expect(res.status).toBe(302);
|
||||||
expect(res.headers.get("Location")).toContain("trakt.tv/oauth/authorize");
|
const location = res.headers.get("Location") || "";
|
||||||
expect(res.headers.get("Location")).toContain("client_id=trakt-client-id");
|
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", async () => {
|
it("should handle callback and exchange code using tenantId from state", async () => {
|
||||||
redis.get.mockImplementation((key) => {
|
(redis.get as any).mockImplementation((key: string) => {
|
||||||
if (key.includes("config:trakt")) return Promise.resolve(mockProviderConfig);
|
if (key.includes("config:trakt")) return Promise.resolve(mockProviderConfig);
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
});
|
});
|
||||||
@@ -41,19 +44,26 @@ describe("Auth Integration", () => {
|
|||||||
refresh_token: "exchange-refresh-token",
|
refresh_token: "exchange-refresh-token",
|
||||||
expires_in: 3600,
|
expires_in: 3600,
|
||||||
}),
|
}),
|
||||||
}),
|
} as any),
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await app.request(`${AUTH_PREFIX}/callback?state=trakt&code=temporary-auth-code`);
|
const res = await app.request(
|
||||||
|
`${AUTH_PREFIX}/callback?state=${tenantId}:trakt&code=temporary-auth-code`,
|
||||||
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(302);
|
expect(res.status).toBe(302);
|
||||||
expect(res.headers.get("Location")).toBe("/app/success?provider=trakt");
|
expect(res.headers.get("Location")).toBe(`/app/success?provider=trakt&tenantId=${tenantId}`);
|
||||||
expect(redis.set).toHaveBeenCalled();
|
expect(redis.set).toHaveBeenCalledWith(
|
||||||
|
`tenant:${tenantId}:provider:trakt:access_token`,
|
||||||
|
"exchange-access-token",
|
||||||
|
"EX",
|
||||||
|
3600,
|
||||||
|
);
|
||||||
expect(fetchSpy).toHaveBeenCalled();
|
expect(fetchSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return 404 if provider not configured during login", async () => {
|
it("should return 404 if provider not configured during login", async () => {
|
||||||
const res = await app.request(`${AUTH_PREFIX}/unknown-provider/login`);
|
const res = await app.request(`${AUTH_PREFIX}/unknown-provider/login?tenantId=${tenantId}`);
|
||||||
|
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
import { API_PREFIX } from "../../src/constants";
|
import { API_PREFIX } from "../../src/constants";
|
||||||
import { redis } from "../../src/core/RedisClient";
|
import { redis } from "../../src/core/RedisClient";
|
||||||
@@ -6,8 +5,8 @@ import { app } from "../../src/index";
|
|||||||
|
|
||||||
describe("Config Integration", () => {
|
describe("Config Integration", () => {
|
||||||
it("should list all configured providers", async () => {
|
it("should list all configured providers", async () => {
|
||||||
redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
|
(redis.keys as any).mockReturnValue(Promise.resolve(["config:trakt"]));
|
||||||
redis.get.mockImplementation(() =>
|
(redis.get as any).mockImplementation(() =>
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
clientId: "trakt-client-id",
|
clientId: "trakt-client-id",
|
||||||
@@ -73,7 +72,8 @@ describe("Config Integration", () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should delete a provider configuration", async () => {
|
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`, {
|
const res = await app.request(`${API_PREFIX}/config/trakt`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -82,6 +82,8 @@ describe("Config Integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(redis.del).toHaveBeenCalled();
|
expect(redis.del).toHaveBeenCalledWith("config:trakt");
|
||||||
|
expect(redis.keys).toHaveBeenCalledWith("tenant:*:provider:trakt:*");
|
||||||
|
expect(redis.del).toHaveBeenCalledWith("tenant:1:provider:trakt:token");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
import { API_PREFIX } from "../../src/constants";
|
import { API_PREFIX } from "../../src/constants";
|
||||||
import { redis } from "../../src/core/RedisClient";
|
import { redis } from "../../src/core/RedisClient";
|
||||||
@@ -23,12 +22,15 @@ describe("Dashboard & Common Integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should return 500 for internal errors", async () => {
|
it("should return 500 for internal errors", async () => {
|
||||||
redis.keys.mockImplementationOnce(() => {
|
(redis.keys as any).mockImplementationOnce(() => {
|
||||||
throw new Error("Redis Crash");
|
throw new Error("Redis Crash");
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request(`${API_PREFIX}/status`, {
|
const res = await app.request(`${API_PREFIX}/status`, {
|
||||||
headers: { Authorization: "Bearer test-api-key" },
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
"X-Tenant-ID": "test-tenant",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(500);
|
expect(res.status).toBe(500);
|
||||||
|
|||||||
Reference in New Issue
Block a user