Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions app/api/debrid/premiumize/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";
import { addUserAccount } from "@/lib/actions/user-accounts";
import PremiumizeClient from "@/lib/clients/premiumize";
import { AccountType } from "@/lib/types";

/**
* OAuth Callback Handler for Premiumize Authorization Code Flow
* Receives the authorization code from Premiumize redirect
* Exchanges code for access token and stores account
*/
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get("code");
const state = searchParams.get("state");
const error = searchParams.get("error");
const errorDescription = searchParams.get("error_description");

// ============================================
// HANDLE AUTHORIZATION ERRORS FROM PREMIUMIZE
// ============================================
if (error) {
const errorMsg = errorDescription || error;
console.error(`[Premiumize OAuth] Authorization error: ${errorMsg}`);

// Redirect to accounts add page with error message
return NextResponse.redirect(
new URL(`/accounts/add?premiumize_error=${encodeURIComponent(errorMsg)}`, request.url)
);
}

// ============================================
// VALIDATE REQUIRED PARAMETERS
// ============================================
if (!code || !state) {
console.error("[Premiumize OAuth] Missing code or state parameter");
return NextResponse.redirect(
new URL("/accounts/add?premiumize_error=" + encodeURIComponent("Invalid callback parameters"), request.url)
);
}

try {
// ============================================
// CSRF PROTECTION: Validate State
// ============================================
const storedState = request.cookies.get("premiumize_oauth_state")?.value;

if (!storedState) {
console.error("[Premiumize OAuth] No stored state found - missing cookie");
return NextResponse.redirect(
new URL("/accounts/add?premiumize_error=" + encodeURIComponent("State cookie not found"), request.url)
);
}

if (storedState !== state) {
console.error(
`[Premiumize OAuth] State mismatch - potential CSRF attack. Stored: ${storedState}, Received: ${state}`
);
return NextResponse.redirect(
new URL(
"/accounts/add?premiumize_error=" +
encodeURIComponent("State validation failed - potential CSRF attack"),
request.url
)
);
}

// ============================================
// VERIFY USER IS AUTHENTICATED
// ============================================
const session = await auth.api.getSession({
headers: await headers(),
});

if (!session) {
console.warn("[Premiumize OAuth] No active session - redirecting to login");
return NextResponse.redirect(new URL("/login", request.url));
}

// ============================================
// EXCHANGE AUTHORIZATION CODE FOR ACCESS TOKEN
// ============================================
console.debug(`[Premiumize OAuth] Exchanging code for access token for user: ${session.user.id}`);

let accessToken: string;
try {
accessToken = await PremiumizeClient.exchangeCodeForToken(code);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : "Token exchange failed";
console.error(`[Premiumize OAuth] Token exchange error: ${errorMsg}`);
return NextResponse.redirect(
new URL(`/accounts/add?premiumize_error=${encodeURIComponent(errorMsg)}`, request.url)
);
}

// ============================================
// VERIFY TOKEN BY FETCHING ACCOUNT INFO
// ============================================
console.debug("[Premiumize OAuth] Verifying token validity");

let account;
try {
account = await PremiumizeClient.getUser(`Bearer ${accessToken}`);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : "Token verification failed";
console.error(`[Premiumize OAuth] Token verification error: ${errorMsg}`);
return NextResponse.redirect(
new URL(`/accounts/add?premiumize_error=${encodeURIComponent(errorMsg)}`, request.url)
);
}

// ============================================
// STORE ACCOUNT IN DATABASE
// ============================================
console.debug(`[Premiumize OAuth] Storing Premiumize account for user: ${session.user.id}`);

try {
await addUserAccount({
type: AccountType.PREMIUMIZE,
apiKey: `Bearer ${accessToken}`,
name: account.name || `Premiumize ${account.id}`,
});
} catch (error) {
const errorMsg = error instanceof Error ? error.message : "Failed to store account";
console.error(`[Premiumize OAuth] Account storage error: ${errorMsg}`);
return NextResponse.redirect(
new URL(`/accounts/add?premiumize_error=${encodeURIComponent(errorMsg)}`, request.url)
);
}

// ============================================
// SUCCESS: REDIRECT TO ONBOARDING
// ============================================
console.info(`[Premiumize OAuth] Successfully connected account for user: ${session.user.id}`);

// Clear state cookie and redirect with cache-invalidation flag
// The _timestamp param forces React Query to refetch by invalidating its cache
const dashboardUrl = new URL("/dashboard", request.url);
dashboardUrl.searchParams.set("_account_added", "true");
const response = NextResponse.redirect(dashboardUrl);
response.cookies.delete("premiumize_oauth_state");

return response;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : "Unknown error";
console.error(`[Premiumize OAuth] Unexpected error: ${errorMsg}`, error);
return NextResponse.redirect(
new URL(`/accounts/add?premiumize_error=${encodeURIComponent("Unexpected error occurred")}`, request.url)
);
}
}

/**
* Optional POST handler for client-side token exchange
* If frontend wants to handle token exchange client-side (less recommended)
*/
export async function POST(request: NextRequest) {
try {
const { code, state } = await request.json();

if (!code || !state) {
return NextResponse.json({ error: "Missing code or state" }, { status: 400 });
}

// Validate state
const storedState = request.cookies.get("premiumize_oauth_state")?.value;
if (!storedState || storedState !== state) {
return NextResponse.json({ error: "State validation failed" }, { status: 400 });
}

// Exchange code for token
let accessToken: string;
try {
accessToken = await PremiumizeClient.exchangeCodeForToken(code);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Token exchange failed" },
{ status: 400 }
);
}

return NextResponse.json({ apiKey: `Bearer ${accessToken}` });
} catch (error) {
console.error("[Premiumize OAuth] POST handler error:", error);
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
}
8 changes: 4 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 43 additions & 17 deletions components/add-account-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { RealDebridClient, TorBoxClient, AllDebridClient, PremiumizeClient } fro
import { Select, SelectItem, SelectValue, SelectContent, SelectTrigger } from "./ui/select";
import { useAddUserAccount } from "@/hooks/use-user-accounts";
import { SectionDivider } from "@/components/section-divider";
import { useState } from "react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { handleError } from "@/lib/utils/error-handling";
import { formatAccountType } from "@/lib/utils";
Expand All @@ -22,6 +22,7 @@ export function AddAccountForm() {
const [isLoadingOAuth, setIsLoadingOAuth] = useState<"alldebrid" | "torbox" | "realdebrid" | "premiumize" | null>(
null
);
const isPremiumizeConfigured = useMemo(() => PremiumizeClient.isOAuthConfigured(), []);

const form = useForm<z.infer<typeof accountSchema>>({
resolver: zodResolver(accountSchema),
Expand All @@ -36,6 +37,20 @@ export function AddAccountForm() {
addAccount.mutate(values, { onSuccess: () => form.reset() });
}

/**
* Generate random state for OAuth CSRF protection
*/
function generateRandomState(length: number): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
let result = "";
const randomValues = new Uint8Array(length);
crypto.getRandomValues(randomValues);
for (let i = 0; i < length; i++) {
result += chars[randomValues[i] % chars.length];
}
return result;
}

async function handleAllDebridLogin() {
setIsLoadingOAuth("alldebrid");
try {
Expand Down Expand Up @@ -86,12 +101,21 @@ export function AddAccountForm() {
async function handlePremiumizeLogin() {
setIsLoadingOAuth("premiumize");
try {
const { redirect_url } = await PremiumizeClient.getAuthPin();
window.open(redirect_url, "_blank", "noreferrer");
toast.info("Please copy your Premiumize API key and paste it in the form above");
// Generate random state for CSRF protection
const state = generateRandomState(32);

// Store state in cookie (will be verified in callback)
// Using document.cookie to match backend cookie handling
const stateValue = encodeURIComponent(state);
document.cookie = `premiumize_oauth_state=${stateValue}; path=/; max-age=600; SameSite=Lax`;

// Get authorization URL
const authUrl = PremiumizeClient.getAuthorizationUrl(state);

// Redirect user to Premiumize authorization endpoint
window.location.href = authUrl;
} catch (error) {
handleError(error);
} finally {
setIsLoadingOAuth(null);
}
}
Expand Down Expand Up @@ -182,18 +206,20 @@ export function AddAccountForm() {
"AllDebrid"
)}
</Button>
<Button
variant="outline"
type="button"
className="w-full"
onClick={handlePremiumizeLogin}
disabled={!!isLoadingOAuth || addAccount.isPending}>
{isLoadingOAuth === "premiumize" ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Premiumize"
)}
</Button>
{isPremiumizeConfigured && (
<Button
variant="outline"
type="button"
className="w-full"
onClick={handlePremiumizeLogin}
disabled={!!isLoadingOAuth || addAccount.isPending}>
{isLoadingOAuth === "premiumize" ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Premiumize"
)}
</Button>
)}
</div>
</div>
</form>
Expand Down
12 changes: 12 additions & 0 deletions components/auth/auth-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ export function AuthProvider({ children }: AuthProviderProps) {
const [selectedAccountId, setSelectedAccountId] = useState<string | null>(null);
const [isLoggingOut, setIsLoggingOut] = useState(false);

// Handle OAuth callback - force refetch of accounts
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("_account_added") === "true") {
// Force an immediate refetch of accounts
refetch();
// Clean up the URL parameter
const newUrl = window.location.pathname;
window.history.replaceState(null, "", newUrl);
}
}, [refetch]);

// `rerender-memo` - Memoize account ID selection (involves array operations)
const currentAccountId = useMemo(() => {
if (accountsLength === 0) return null;
Expand Down
Loading