refactor: implement universal oauth callback and auto-generated redirect URIs

This commit is contained in:
ramvignesh-b
2026-05-11 11:37:04 +05:30
parent ea91ba9676
commit 08025303c1
5 changed files with 83 additions and 91 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import { z } from "zod";
export const ProviderConfigSchema = z.object({
clientId: z.string(),
clientSecret: z.string(),
redirectUri: z.string().url(),
redirectUri: z.string().url().optional(),
authUrl: z.string().url(),
tokenUrl: z.string().url(),
scope: z.string(),
+5 -4
View File
@@ -14,17 +14,18 @@ export class GenericProvider implements OAuthProvider {
private config: ProviderConfig,
) {}
getAuthUrl(): string {
getAuthUrl(state: string, redirectUri: string): string {
const params = new URLSearchParams({
response_type: "code",
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
redirect_uri: redirectUri,
scope: this.config.scope,
state,
});
return `${this.config.authUrl}?${params.toString()}`;
}
async exchangeCode(code: string): Promise<TokenResponse> {
async exchangeCode(code: string, redirectUri: string): Promise<TokenResponse> {
const response = await fetch(this.config.tokenUrl, {
method: "POST",
headers: {
@@ -35,7 +36,7 @@ export class GenericProvider implements OAuthProvider {
code,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
redirect_uri: this.config.redirectUri,
redirect_uri: redirectUri,
grant_type: "authorization_code",
}),
});
+2 -2
View File
@@ -6,7 +6,7 @@ export interface TokenResponse {
export interface OAuthProvider {
name: string;
getAuthUrl(): string;
exchangeCode(code: string): Promise<TokenResponse>;
getAuthUrl(state: string, redirectUri: string): string;
exchangeCode(code: string, redirectUri: string): Promise<TokenResponse>;
refreshToken(refreshToken: string): Promise<TokenResponse>;
}
+17 -14
View File
@@ -15,25 +15,29 @@ authRoutes.get("/:provider/login", async (c) => {
return c.json(
{
error: "Configuration Not Found",
message: `Provider '${providerName}' is not configured in the registry. Please add it via the dashboard first.`,
message: `Provider '${providerName}' is not configured.`,
},
404,
);
}
const provider = new GenericProvider(providerName, providerConfig);
return c.redirect(provider.getAuthUrl());
const url = new URL(c.req.url);
const redirectUri = providerConfig.redirectUri || `${url.origin}/auth/callback`;
return c.redirect(provider.getAuthUrl(providerName, redirectUri));
});
authRoutes.get("/:provider/callback", async (c) => {
const providerName = c.req.param("provider");
authRoutes.get("/callback", async (c) => {
const providerName = c.req.query("state");
const code = c.req.query("code");
if (!code) {
if (!providerName || !code) {
return c.json(
{
error: "Authorization Code Missing",
message: "The provider did not return an authorization code.",
error: "Invalid Request",
message: "Missing state (provider) or authorization code.",
},
400,
);
@@ -46,17 +50,20 @@ authRoutes.get("/:provider/callback", async (c) => {
return c.json(
{
error: "Configuration Not Found",
message: `Provider '${providerName}' was configured during login but its configuration is missing now.`,
message: `Provider '${providerName}' is not configured.`,
},
404,
);
}
const url = new URL(c.req.url);
const redirectUri = providerConfig.redirectUri || `${url.origin}/auth/callback`;
const provider = new GenericProvider(providerName, providerConfig);
const tokenManager = new TokenManager(redis, provider);
try {
const tokens = await provider.exchangeCode(code);
const tokens = await provider.exchangeCode(code, redirectUri);
await tokenManager.saveTokens(providerName, tokens);
return c.html(`
@@ -76,7 +83,6 @@ authRoutes.get("/:provider/callback", async (c) => {
</div>
<h2 class="card-title text-2xl font-bold">Authenticated!</h2>
<p class="opacity-70 mt-2">Successfully connected to <strong>${providerName}</strong>.</p>
<p class="text-sm opacity-50">You can now safely close this window and return to the dashboard.</p>
<div class="card-actions mt-6">
<a href="/dashboard" class="btn btn-primary">Go to Dashboard</a>
</div>
@@ -86,10 +92,7 @@ authRoutes.get("/:provider/callback", async (c) => {
</html>
`);
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: "An unexpected error occurred during token exchange.";
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred.";
console.error(`[OAuth Error] ${errorMessage}`);
return c.json(
{
+58 -70
View File
@@ -10,14 +10,8 @@
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
<script src="https://unpkg.com/@phosphor-icons/web@2.1.1"></script>
<style>
.font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.ph {
font-size: 1.25rem;
vertical-align: middle;
}
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.ph { font-size: 1.25rem; vertical-align: middle; }
</style>
</head>
@@ -25,12 +19,10 @@
<div class="navbar bg-base-100 shadow-sm px-4 md:px-8 border-b border-base-300">
<div class="flex-1">
<div class="flex items-center gap-2">
<div
class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-primary-content font-bold text-lg">
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-primary-content font-bold text-lg">
<i class="ph ph-shield-check"></i>
</div>
<a class="text-xl font-bold tracking-tight">Auth Server <span
class="text-xs font-normal opacity-50 ml-1">v1.0</span></a>
<a class="text-xl font-bold tracking-tight">Auth Server <span class="text-xs font-normal opacity-50 ml-1">v1.0</span></a>
</div>
</div>
<div class="flex-none">
@@ -55,8 +47,7 @@
<form id="configForm" class="space-y-4">
<div class="form-control">
<label class="label py-1"><span class="label-text font-semibold opacity-70">Provider
ID</span></label>
<label class="label py-1"><span class="label-text font-semibold opacity-70">Provider ID</span></label>
<input type="text" id="providerName" placeholder="e.g. trakt" required
class="input input-bordered focus:input-primary" />
</div>
@@ -64,48 +55,45 @@
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest">Credentials</div>
<div class="form-control">
<label class="label py-1"><span class="label-text font-semibold opacity-70">Client
ID</span></label>
<input type="text" id="clientId" placeholder="OAuth client id" required
class="input input-bordered focus:input-primary" />
<label class="label py-1"><span class="label-text font-semibold opacity-70">Client ID</span></label>
<input type="text" id="clientId" placeholder="OAuth client id" required class="input input-bordered focus:input-primary" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text font-semibold opacity-70">Client
Secret</span></label>
<input type="password" id="clientSecret" placeholder="OAuth client secret" required
class="input input-bordered focus:input-primary" />
<label class="label py-1"><span class="label-text font-semibold opacity-70">Client Secret</span></label>
<input type="password" id="clientSecret" placeholder="OAuth client secret" required class="input input-bordered focus:input-primary" />
</div>
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest">Endpoints</div>
<div class="form-control">
<label class="label py-1"><span class="label-text font-semibold opacity-70">Auth
URL</span></label>
<label class="label py-1"><span class="label-text font-semibold opacity-70">Auth URL</span></label>
<input type="url" id="authUrl" placeholder="https://trakt.tv/oauth/authorize" required
class="input input-bordered focus:input-primary" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text font-semibold opacity-70">Token
URL</span></label>
<label class="label py-1"><span class="label-text font-semibold opacity-70">Token URL</span></label>
<input type="url" id="tokenUrl" placeholder="https://api.trakt.tv/oauth/token" required
class="input input-bordered focus:input-primary" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text font-semibold opacity-70">Redirect
URI</span></label>
<input type="url" id="redirectUri" placeholder="http://localhost:3000/auth/trakt/callback"
required class="input input-bordered focus:input-primary" />
<label class="label py-1">
<span class="label-text font-semibold opacity-70">Redirect URI</span>
<span class="label-text-alt opacity-50 italic">Optional</span>
</label>
<input type="url" id="redirectUri" placeholder="Auto-generated if empty"
class="input input-bordered focus:input-primary" />
<label class="label py-1">
<span class="label-text-alt opacity-40 font-mono text-[10px]" id="defaultRedirectUriHint"></span>
</label>
</div>
<div class="form-control">
<label class="label py-1"><span
class="label-text font-semibold opacity-70">Scope</span>(optional)</label>
<input type="text" id="scope" placeholder="public"
class="input input-bordered focus:input-primary" />
<label class="label py-1"><span class="label-text font-semibold opacity-70">Scope</span></label>
<input type="text" id="scope" placeholder="public" class="input input-bordered focus:input-primary" />
</div>
<div class="card-actions pt-4">
<button type="submit" class="btn btn-primary w-full shadow-md">
<i class="ph ph-plus-bold"></i>
<i class="ph ph-plus-bold mr-1"></i>
Save Configuration
</button>
</div>
@@ -132,12 +120,9 @@
<table class="table w-full">
<thead>
<tr class="bg-base-200/50">
<th class="rounded-none font-bold uppercase tracking-wider text-xs opacity-60">
Provider</th>
<th class="rounded-none font-bold uppercase tracking-wider text-xs opacity-60">Provider</th>
<th class="font-bold uppercase tracking-wider text-xs opacity-60">Client ID</th>
<th
class="rounded-none text-right font-bold uppercase tracking-wider text-xs opacity-60">
Actions</th>
<th class="rounded-none text-right font-bold uppercase tracking-wider text-xs opacity-60">Actions</th>
</tr>
</thead>
<tbody id="providerTableBody">
@@ -167,6 +152,11 @@
const apiKeyInput = document.getElementById('apiKey');
const configForm = document.getElementById('configForm');
const providerTableBody = document.getElementById('providerTableBody');
const defaultRedirectUriHint = document.getElementById('defaultRedirectUriHint');
if (defaultRedirectUriHint) {
defaultRedirectUriHint.textContent = `Default: ${window.location.origin}/auth/callback`;
}
let providerData = {};
@@ -175,7 +165,7 @@
const alert = toast.querySelector('.alert');
const msgSpan = document.getElementById('notificationMessage');
alert.className = `alert alert-${type}`;
alert.className = `alert alert-${type} shadow-lg`;
msgSpan.textContent = message;
toast.style.display = 'block';
@@ -186,46 +176,46 @@
async function fetchProviders() {
const apiKey = apiKeyInput.value.trim();
if (!apiKey) {
return;
}
if (!apiKey) return;
try {
const response = await fetch('/api/config', {
headers: {
'Authorization': `Bearer ${apiKey}`
}
headers: { 'Authorization': `Bearer ${apiKey}` }
});
if (!response.ok) throw new Error(await response.text());
const providers = await response.json();
providerData = providers;
providerTableBody.innerHTML = '';
const entries = Object.entries(providers);
if (entries.length === 0) {
providerTableBody.innerHTML = '<tr><td colspan="3" class="text-center py-8 opacity-50">No providers configured yet.</td></tr>';
providerTableBody.innerHTML = '<tr><td colspan="3" class="text-center py-12 opacity-50">No providers configured.</td></tr>';
return;
}
for (const [name, config] of entries) {
const row = document.createElement('tr');
row.className = "hover";
row.className = "hover:bg-base-200/50 transition-colors";
row.innerHTML = `
<td class="font-bold text-base-content/80">${name}</td>
<td><code class="text-xs bg-base-300 px-2 py-1 rounded-md font-mono">${config.clientId}</code></td>
<td class="text-right">
<div class="flex gap-2 justify-end">
<a href="/auth/${name}/login" target="_blank" class="btn btn-xs btn-primary">Connect</a>
<button type="button" onclick="editProvider('${name}')" class="btn btn-xs btn-outline">Edit</button>
</div>
</td>
`;
<td class="font-bold text-base-content/80">${name}</td>
<td><code class="text-xs bg-base-200 px-2 py-1 rounded-md font-mono">${config.clientId}</code></td>
<td class="text-right">
<div class="flex gap-2 justify-end">
<a href="/auth/${name}/login" target="_blank" class="btn btn-xs btn-primary">
<i class="ph ph-link"></i> Connect
</a>
<button type="button" onclick="window.editProvider('${name}')" class="btn btn-xs btn-outline">
<i class="ph ph-pencil-simple"></i> Edit
</button>
</div>
</td>
`;
providerTableBody.appendChild(row);
};
}
} catch (error) {
console.error('Error fetching providers:', error);
providerTableBody.innerHTML = `<tr><td colspan="3" class="text-center text-error py-4">Failed to load: ${error.message}</td></tr>`;
providerTableBody.innerHTML = `<tr><td colspan="3" class="text-center text-error py-8">Failed to load: ${error.message}</td></tr>`;
}
}
@@ -238,17 +228,18 @@
document.getElementById('clientSecret').value = config.clientSecret;
document.getElementById('authUrl').value = config.authUrl;
document.getElementById('tokenUrl').value = config.tokenUrl;
document.getElementById('redirectUri').value = config.redirectUri;
document.getElementById('redirectUri').value = config.redirectUri || '';
document.getElementById('scope').value = config.scope;
document.getElementById('configForm').scrollIntoView({ behavior: 'smooth' });
document.getElementById('providerName').focus();
} configForm.addEventListener('submit', async (e) => {
};
configForm.addEventListener('submit', async (e) => {
e.preventDefault();
const apiKey = apiKeyInput.value.trim();
if (!apiKey) {
showNotification('Please enter your API Key in the top right corner', 'error');
showNotification('Please enter your Master API Key', 'error');
return;
}
@@ -258,7 +249,7 @@
clientSecret: document.getElementById('clientSecret').value.trim(),
authUrl: document.getElementById('authUrl').value.trim(),
tokenUrl: document.getElementById('tokenUrl').value.trim(),
redirectUri: document.getElementById('redirectUri').value.trim(),
redirectUri: document.getElementById('redirectUri').value.trim() || undefined,
scope: document.getElementById('scope').value.trim(),
};
@@ -272,10 +263,7 @@
body: JSON.stringify(config)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to save config');
}
if (!response.ok) throw new Error(await response.text());
showNotification('Configuration saved successfully!');
fetchProviders();