feat: Improve OAuth authorization error handling and client validation

- Add comprehensive error handling for OAuth authorization requests
- Implement detailed error redirects for invalid client, redirect URI, and disabled applications
- Update Authorizing component to handle OAuth parameters more robustly
- Refactor client retrieval to select specific fields and improve security
- Enhance error messaging for OAuth authorization flow
This commit is contained in:
wood chen 2025-02-20 02:24:06 +08:00
parent bd6b2b747d
commit 1d0fe64fdb
4 changed files with 106 additions and 23 deletions

View File

@ -1,3 +1,5 @@
import { redirect } from "next/navigation";
import { getClientByClientId } from "@/lib/dto/client";
import { Authorizing } from "@/components/auth/authorizing";
@ -8,30 +10,56 @@ export interface AuthorizeParams extends Record<string, string> {
redirect_uri: string;
}
export default async function OAuthAuthorization({
export default async function AuthorizePage({
searchParams,
}: {
searchParams: AuthorizeParams;
}) {
// params invalid
// 检查必要的参数
if (
!searchParams.response_type ||
!searchParams.client_id ||
!searchParams.redirect_uri
) {
throw new Error("Params invalid");
const errorParams = new URLSearchParams({
error: "invalid_request",
error_description: "缺少必要的参数",
});
redirect(`${searchParams.redirect_uri}?${errorParams.toString()}`);
}
// client invalid
const client = await getClientByClientId(searchParams.client_id);
if (!client || client.redirectUri !== searchParams.redirect_uri) {
throw new Error("Client not found");
// 检查应用是否存在
if (!client) {
const errorParams = new URLSearchParams({
error: "invalid_client",
error_description: "应用不存在",
});
redirect(`${searchParams.redirect_uri}?${errorParams.toString()}`);
}
// 检查回调地址是否匹配
if (client.redirectUri !== searchParams.redirect_uri) {
const errorParams = new URLSearchParams({
error: "invalid_request",
error_description: "回调地址不匹配",
});
redirect(`${searchParams.redirect_uri}?${errorParams.toString()}`);
}
// 检查应用是否被禁用
if (!client.enabled) {
const errorParams = new URLSearchParams({
error: "access_denied",
error_description: "此应用已被禁用",
});
redirect(`${searchParams.redirect_uri}?${errorParams.toString()}`);
}
// Authorizing ...
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
<Authorizing />
<Authorizing searchParams={searchParams} />
</div>
);
}

View File

@ -27,6 +27,15 @@ export default async function Q58CallbackPage({
throw new Error("Client Id invalid (code: -1004).");
}
// 检查应用是否被禁用
if (!client.enabled) {
const redirectUri = client.redirectUri;
const redirectUrl = new URL(redirectUri);
redirectUrl.searchParams.set("error", "access_denied");
redirectUrl.searchParams.set("error_description", "该应用已被禁用");
return redirect(redirectUrl.toString());
}
// verify q58 callback
const user = await q58CallbackVerify(searchParams.sso, searchParams.sig);
@ -34,6 +43,14 @@ export default async function Q58CallbackPage({
const authorization = await findAuthorization(user.id, client.id);
if (authorization) {
// 如果授权被禁用,也返回错误
if (!authorization.enabled) {
const redirectUrl = new URL(client.redirectUri);
redirectUrl.searchParams.set("error", "access_denied");
redirectUrl.searchParams.set("error_description", "您的授权已被禁用");
return redirect(redirectUrl.toString());
}
const redirectUrl = await getAuthorizeUrl(oauthParams);
return redirect(redirectUrl);
}

View File

@ -1,38 +1,44 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { getDiscourseSSOUrl } from "@/actions/discourse-sso-url";
export function Authorizing() {
import type { AuthorizeParams } from "@/app/(oauth)/oauth/authorize/page";
interface AuthorizingProps {
searchParams: AuthorizeParams;
}
export function Authorizing({ searchParams }: AuthorizingProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [error, setError] = useState<unknown | null>(null);
const [progress, setProgress] = useState(0);
const signInCallback = useCallback(async () => {
try {
const url = await getDiscourseSSOUrl(searchParams.toString());
const url = await getDiscourseSSOUrl(
new URLSearchParams(searchParams).toString(),
);
router.push(url);
} catch (error) {
setError(error);
}
}, []);
}, [searchParams, router]);
useEffect(() => {
// 模拟进度条
const progressInterval = setInterval(() => {
setProgress((prev) => (prev >= 90 ? 90 : prev + 10));
}, 300);
// 启动进度条动画
const progressTimer = setInterval(() => {
setProgress((prev) => Math.min(prev + 10, 90));
}, 500);
// Delay 3s get sso url go to ...
const timer = setTimeout(signInCallback, 3000);
// 立即开始授权
signInCallback();
return () => {
clearTimeout(timer);
clearInterval(progressInterval);
clearInterval(progressTimer);
};
}, []);
}, [signInCallback]);
return (
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">

View File

@ -3,7 +3,24 @@ import { Prisma as PrismaType } from "@prisma/client";
import { prisma } from "@/lib/prisma";
export async function getClientByClientId(clientId: string) {
return prisma.client.findUnique({ where: { clientId } });
return prisma.client.findUnique({
where: { clientId },
select: {
id: true,
name: true,
clientId: true,
clientSecret: true,
redirectUri: true,
home: true,
logo: true,
description: true,
enabled: true,
allowedUsers: true,
userId: true,
createdAt: true,
updatedAt: true,
},
});
}
export async function createClient(
@ -31,5 +48,20 @@ export async function getClientById(id: string) {
where: {
id,
},
select: {
id: true,
name: true,
clientId: true,
clientSecret: true,
redirectUri: true,
home: true,
logo: true,
description: true,
enabled: true,
allowedUsers: true,
userId: true,
createdAt: true,
updatedAt: true,
},
});
}