mirror of
https://github.com/jnsahaj/tweakcn.git
synced 2026-08-28 23:02:07 +08:00
OAuth 2.0 API for external app integrations (#267)
* OAuth 2.0 API for external app integrations Implements the Authorization Code flow so external apps can authenticate tweakcn users and access their themes/profile via a REST API. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: simplify OAuth API routes and improve efficiency - Extract `requireAuth` helper to eliminate duplicated auth+scope boilerplate across all v1 API routes - Narrow SELECT queries to only fetch needed columns in hot paths (resolveUserFromBearerToken, authenticateClient, authorize, token) - Reuse `generateSecureToken`/`hashSecret` in create-oauth-app script instead of duplicating crypto logic - Unify sign-in handlers and loading state in OAuth authorize page - Use `oauthError` consistently for 404 responses Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * replace demo app with OAuth API documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * add /api/oauth/userinfo endpoint for genericOAuth compatibility Returns flat OIDC-style fields (sub, name, email, picture) so Better Auth's genericOAuth plugin and similar clients work out of the box without custom getUserInfo mapping. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { db } from "@/db";
|
||||
import { oauthApp } from "@/db/schema";
|
||||
import { oauthError } from "@/lib/oauth";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const clientId = req.nextUrl.searchParams.get("client_id");
|
||||
|
||||
if (!clientId) {
|
||||
return oauthError("invalid_request", "Missing client_id");
|
||||
}
|
||||
|
||||
const [app] = await db
|
||||
.select({ name: oauthApp.name, description: oauthApp.description })
|
||||
.from(oauthApp)
|
||||
.where(and(eq(oauthApp.clientId, clientId), eq(oauthApp.isActive, true)))
|
||||
.limit(1);
|
||||
|
||||
if (!app) {
|
||||
return oauthError("invalid_client", "Unknown client_id");
|
||||
}
|
||||
|
||||
return Response.json({ name: app.name, description: app.description });
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { db } from "@/db";
|
||||
import { oauthApp, oauthAuthorizationCode } from "@/db/schema";
|
||||
import { OAUTH_AUTHORIZATION_CODE_EXPIRY_SECONDS } from "@/lib/constants";
|
||||
import {
|
||||
generateSecureToken,
|
||||
oauthError,
|
||||
parseScopes,
|
||||
validateRedirectUri,
|
||||
validateScopes,
|
||||
} from "@/lib/oauth";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import cuid from "cuid";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const params = req.nextUrl.searchParams;
|
||||
|
||||
const clientId = params.get("client_id");
|
||||
const redirectUri = params.get("redirect_uri");
|
||||
const responseType = params.get("response_type");
|
||||
const scopeParam = params.get("scope");
|
||||
const state = params.get("state");
|
||||
const codeChallenge = params.get("code_challenge");
|
||||
const codeChallengeMethod = params.get("code_challenge_method") ?? "S256";
|
||||
|
||||
// Validate required params
|
||||
if (!clientId || !redirectUri || !responseType || !scopeParam) {
|
||||
return oauthError(
|
||||
"invalid_request",
|
||||
"Missing required parameters: client_id, redirect_uri, response_type, scope"
|
||||
);
|
||||
}
|
||||
|
||||
if (responseType !== "code") {
|
||||
return oauthError(
|
||||
"unsupported_response_type",
|
||||
"Only response_type=code is supported"
|
||||
);
|
||||
}
|
||||
|
||||
// Look up OAuth app
|
||||
const [app] = await db
|
||||
.select({ id: oauthApp.id, redirectUris: oauthApp.redirectUris })
|
||||
.from(oauthApp)
|
||||
.where(and(eq(oauthApp.clientId, clientId), eq(oauthApp.isActive, true)))
|
||||
.limit(1);
|
||||
|
||||
if (!app) {
|
||||
return oauthError("invalid_client", "Unknown client_id");
|
||||
}
|
||||
|
||||
// Validate redirect URI
|
||||
if (!validateRedirectUri(redirectUri, app.redirectUris)) {
|
||||
return oauthError("invalid_request", "redirect_uri not registered");
|
||||
}
|
||||
|
||||
// Validate scopes
|
||||
const scopes = parseScopes(scopeParam);
|
||||
if (!validateScopes(scopes)) {
|
||||
return oauthError("invalid_scope", "Invalid or unsupported scope");
|
||||
}
|
||||
|
||||
// Check that the user is logged in
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
// Redirect to the OAuth authorize page which handles sign-in
|
||||
const pageUrl = new URL("/oauth/authorize", req.nextUrl.origin);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
pageUrl.searchParams.set(key, value);
|
||||
});
|
||||
return Response.redirect(pageUrl.toString(), 302);
|
||||
}
|
||||
|
||||
// Generate authorization code
|
||||
const code = generateSecureToken();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(oauthAuthorizationCode).values({
|
||||
id: cuid(),
|
||||
code,
|
||||
appId: app.id,
|
||||
userId: session.user.id,
|
||||
scopes,
|
||||
redirectUri,
|
||||
codeChallenge: codeChallenge ?? null,
|
||||
codeChallengeMethod: codeChallenge ? codeChallengeMethod : null,
|
||||
expiresAt: new Date(
|
||||
now.getTime() + OAUTH_AUTHORIZATION_CODE_EXPIRY_SECONDS * 1000
|
||||
),
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
// Redirect back to the app with the code
|
||||
const redirectUrl = new URL(redirectUri);
|
||||
redirectUrl.searchParams.set("code", code);
|
||||
if (state) redirectUrl.searchParams.set("state", state);
|
||||
|
||||
return Response.redirect(redirectUrl.toString(), 302);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { db } from "@/db";
|
||||
import { oauthToken } from "@/db/schema";
|
||||
import { hashToken, oauthError } from "@/lib/oauth";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.formData().catch(() => null);
|
||||
if (!body) {
|
||||
return oauthError("invalid_request", "Request body must be form-encoded");
|
||||
}
|
||||
|
||||
const token = body.get("token") as string | null;
|
||||
if (!token) {
|
||||
return oauthError("invalid_request", "Missing required parameter: token");
|
||||
}
|
||||
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
// Try to match as access token or refresh token
|
||||
const [record] = await db
|
||||
.select({ id: oauthToken.id })
|
||||
.from(oauthToken)
|
||||
.where(
|
||||
or(
|
||||
eq(oauthToken.accessTokenHash, tokenHash),
|
||||
eq(oauthToken.refreshTokenHash, tokenHash)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (record) {
|
||||
await db
|
||||
.update(oauthToken)
|
||||
.set({ revokedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(oauthToken.id, record.id));
|
||||
}
|
||||
|
||||
// RFC 7009: always return 200 even if token not found
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { db } from "@/db";
|
||||
import { oauthAuthorizationCode, oauthToken } from "@/db/schema";
|
||||
import {
|
||||
authenticateClient,
|
||||
createTokenPair,
|
||||
hashToken,
|
||||
oauthError,
|
||||
verifyCodeChallenge,
|
||||
} from "@/lib/oauth";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.formData().catch(() => null);
|
||||
if (!body) {
|
||||
return oauthError("invalid_request", "Request body must be form-encoded");
|
||||
}
|
||||
|
||||
const grantType = body.get("grant_type") as string | null;
|
||||
|
||||
if (grantType === "authorization_code") {
|
||||
return handleAuthorizationCode(body);
|
||||
}
|
||||
|
||||
if (grantType === "refresh_token") {
|
||||
return handleRefreshToken(body);
|
||||
}
|
||||
|
||||
return oauthError("unsupported_grant_type", "Supported: authorization_code, refresh_token");
|
||||
}
|
||||
|
||||
async function handleAuthorizationCode(body: FormData) {
|
||||
const clientId = body.get("client_id") as string | null;
|
||||
const clientSecret = body.get("client_secret") as string | null;
|
||||
const code = body.get("code") as string | null;
|
||||
const redirectUri = body.get("redirect_uri") as string | null;
|
||||
const codeVerifier = body.get("code_verifier") as string | null;
|
||||
|
||||
if (!clientId || !clientSecret || !code || !redirectUri) {
|
||||
return oauthError(
|
||||
"invalid_request",
|
||||
"Missing required parameters: client_id, client_secret, code, redirect_uri"
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticate the client
|
||||
const app = await authenticateClient(clientId, clientSecret);
|
||||
if (!app) {
|
||||
return oauthError("invalid_client", "Invalid client credentials", 401);
|
||||
}
|
||||
|
||||
// Look up the authorization code
|
||||
const [authCode] = await db
|
||||
.select({
|
||||
id: oauthAuthorizationCode.id,
|
||||
expiresAt: oauthAuthorizationCode.expiresAt,
|
||||
redirectUri: oauthAuthorizationCode.redirectUri,
|
||||
codeChallenge: oauthAuthorizationCode.codeChallenge,
|
||||
codeChallengeMethod: oauthAuthorizationCode.codeChallengeMethod,
|
||||
userId: oauthAuthorizationCode.userId,
|
||||
scopes: oauthAuthorizationCode.scopes,
|
||||
})
|
||||
.from(oauthAuthorizationCode)
|
||||
.where(
|
||||
and(
|
||||
eq(oauthAuthorizationCode.code, code),
|
||||
eq(oauthAuthorizationCode.appId, app.id),
|
||||
isNull(oauthAuthorizationCode.usedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!authCode) {
|
||||
return oauthError("invalid_grant", "Invalid or already used authorization code");
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if (new Date() > authCode.expiresAt) {
|
||||
return oauthError("invalid_grant", "Authorization code expired");
|
||||
}
|
||||
|
||||
// Check redirect URI matches
|
||||
if (authCode.redirectUri !== redirectUri) {
|
||||
return oauthError("invalid_grant", "redirect_uri mismatch");
|
||||
}
|
||||
|
||||
// Verify PKCE if code challenge was provided during authorization
|
||||
if (authCode.codeChallenge) {
|
||||
if (!codeVerifier) {
|
||||
return oauthError("invalid_request", "code_verifier required for PKCE");
|
||||
}
|
||||
if (
|
||||
!verifyCodeChallenge(
|
||||
codeVerifier,
|
||||
authCode.codeChallenge,
|
||||
authCode.codeChallengeMethod ?? "S256"
|
||||
)
|
||||
) {
|
||||
return oauthError("invalid_grant", "PKCE verification failed");
|
||||
}
|
||||
}
|
||||
|
||||
// Mark code as used
|
||||
await db
|
||||
.update(oauthAuthorizationCode)
|
||||
.set({ usedAt: new Date() })
|
||||
.where(eq(oauthAuthorizationCode.id, authCode.id));
|
||||
|
||||
// Create tokens
|
||||
const tokenResponse = await createTokenPair(
|
||||
app.id,
|
||||
authCode.userId,
|
||||
authCode.scopes
|
||||
);
|
||||
|
||||
return Response.json(tokenResponse);
|
||||
}
|
||||
|
||||
async function handleRefreshToken(body: FormData) {
|
||||
const clientId = body.get("client_id") as string | null;
|
||||
const clientSecret = body.get("client_secret") as string | null;
|
||||
const refreshToken = body.get("refresh_token") as string | null;
|
||||
|
||||
if (!clientId || !clientSecret || !refreshToken) {
|
||||
return oauthError(
|
||||
"invalid_request",
|
||||
"Missing required parameters: client_id, client_secret, refresh_token"
|
||||
);
|
||||
}
|
||||
|
||||
const app = await authenticateClient(clientId, clientSecret);
|
||||
if (!app) {
|
||||
return oauthError("invalid_client", "Invalid client credentials", 401);
|
||||
}
|
||||
|
||||
// Look up the refresh token
|
||||
const refreshTokenHash = hashToken(refreshToken);
|
||||
const [tokenRecord] = await db
|
||||
.select({
|
||||
id: oauthToken.id,
|
||||
userId: oauthToken.userId,
|
||||
scopes: oauthToken.scopes,
|
||||
refreshTokenExpiresAt: oauthToken.refreshTokenExpiresAt,
|
||||
})
|
||||
.from(oauthToken)
|
||||
.where(
|
||||
and(
|
||||
eq(oauthToken.refreshTokenHash, refreshTokenHash),
|
||||
eq(oauthToken.appId, app.id),
|
||||
isNull(oauthToken.revokedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!tokenRecord) {
|
||||
return oauthError("invalid_grant", "Invalid refresh token");
|
||||
}
|
||||
|
||||
if (
|
||||
tokenRecord.refreshTokenExpiresAt &&
|
||||
new Date() > tokenRecord.refreshTokenExpiresAt
|
||||
) {
|
||||
return oauthError("invalid_grant", "Refresh token expired");
|
||||
}
|
||||
|
||||
// Revoke the old token pair
|
||||
await db
|
||||
.update(oauthToken)
|
||||
.set({ revokedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(oauthToken.id, tokenRecord.id));
|
||||
|
||||
// Issue new token pair
|
||||
const tokenResponse = await createTokenPair(
|
||||
app.id,
|
||||
tokenRecord.userId,
|
||||
tokenRecord.scopes
|
||||
);
|
||||
|
||||
return Response.json(tokenResponse);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { db } from "@/db";
|
||||
import { user as userTable } from "@/db/schema";
|
||||
import { oauthError, requireScope, resolveUserFromBearerToken } from "@/lib/oauth";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* OpenID Connect-style userinfo endpoint.
|
||||
* Returns flat user fields for compatibility with generic OAuth clients
|
||||
* (e.g. Better Auth's genericOAuth plugin).
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const tokenData = await resolveUserFromBearerToken(
|
||||
req.headers.get("authorization")
|
||||
);
|
||||
|
||||
if (!tokenData) {
|
||||
return oauthError("invalid_token", "Invalid or expired access token", 401);
|
||||
}
|
||||
|
||||
if (!requireScope(tokenData.scopes, "profile:read")) {
|
||||
return oauthError("insufficient_scope", "Requires profile:read scope", 403);
|
||||
}
|
||||
|
||||
const [profile] = await db
|
||||
.select({
|
||||
id: userTable.id,
|
||||
name: userTable.name,
|
||||
email: userTable.email,
|
||||
image: userTable.image,
|
||||
})
|
||||
.from(userTable)
|
||||
.where(eq(userTable.id, tokenData.userId))
|
||||
.limit(1);
|
||||
|
||||
if (!profile) {
|
||||
return oauthError("invalid_token", "User not found", 401);
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
sub: profile.id,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
picture: profile.image,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { db } from "@/db";
|
||||
import { user as userTable } from "@/db/schema";
|
||||
import { oauthError, requireAuth } from "@/lib/oauth";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const auth = await requireAuth(req, "profile:read");
|
||||
if (auth.error) return auth.error;
|
||||
|
||||
const [profile] = await db
|
||||
.select({
|
||||
id: userTable.id,
|
||||
name: userTable.name,
|
||||
email: userTable.email,
|
||||
image: userTable.image,
|
||||
})
|
||||
.from(userTable)
|
||||
.where(eq(userTable.id, auth.tokenData.userId))
|
||||
.limit(1);
|
||||
|
||||
if (!profile) {
|
||||
return oauthError("invalid_token", "User not found", 401);
|
||||
}
|
||||
|
||||
return Response.json({ data: profile });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { db } from "@/db";
|
||||
import { theme as themeTable } from "@/db/schema";
|
||||
import { oauthError, requireAuth } from "@/lib/oauth";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ themeId: string }> }
|
||||
) {
|
||||
const auth = await requireAuth(req, "themes:read");
|
||||
if (auth.error) return auth.error;
|
||||
|
||||
const { themeId } = await params;
|
||||
|
||||
const [theme] = await db
|
||||
.select({
|
||||
id: themeTable.id,
|
||||
name: themeTable.name,
|
||||
styles: themeTable.styles,
|
||||
createdAt: themeTable.createdAt,
|
||||
updatedAt: themeTable.updatedAt,
|
||||
})
|
||||
.from(themeTable)
|
||||
.where(
|
||||
and(eq(themeTable.id, themeId), eq(themeTable.userId, auth.tokenData.userId))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!theme) {
|
||||
return oauthError("not_found", "Theme not found", 404);
|
||||
}
|
||||
|
||||
return Response.json({ data: theme });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { db } from "@/db";
|
||||
import { theme as themeTable } from "@/db/schema";
|
||||
import { requireAuth } from "@/lib/oauth";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const auth = await requireAuth(req, "themes:read");
|
||||
if (auth.error) return auth.error;
|
||||
|
||||
const themes = await db
|
||||
.select({
|
||||
id: themeTable.id,
|
||||
name: themeTable.name,
|
||||
styles: themeTable.styles,
|
||||
createdAt: themeTable.createdAt,
|
||||
updatedAt: themeTable.updatedAt,
|
||||
})
|
||||
.from(themeTable)
|
||||
.where(eq(themeTable.userId, auth.tokenData.userId));
|
||||
|
||||
return Response.json({ data: themes });
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import Github from "@/assets/github.svg";
|
||||
import Google from "@/assets/google.svg";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const SCOPE_LABELS: Record<string, string> = {
|
||||
"themes:read": "Read your saved themes",
|
||||
"profile:read": "Read your profile (name, email)",
|
||||
};
|
||||
|
||||
export default function OAuthAuthorizePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [appName, setAppName] = useState<string | null>(null);
|
||||
const [redirecting, setRedirecting] = useState(false);
|
||||
|
||||
const clientId = searchParams.get("client_id");
|
||||
const scopes =
|
||||
searchParams
|
||||
.get("scope")
|
||||
?.split(/[\s,]+/)
|
||||
.filter(Boolean) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!clientId) {
|
||||
setError("Missing client_id parameter");
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/oauth/app-info?client_id=${encodeURIComponent(clientId)}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
setError(data.error_description ?? "Invalid application");
|
||||
} else {
|
||||
setAppName(data.name);
|
||||
}
|
||||
})
|
||||
.catch(() => setError("Failed to validate application"));
|
||||
}, [clientId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || redirecting || error) return;
|
||||
|
||||
setRedirecting(true);
|
||||
const apiUrl = `/api/oauth/authorize?${searchParams.toString()}`;
|
||||
window.location.href = apiUrl;
|
||||
}, [session, searchParams, redirecting, error]);
|
||||
|
||||
const callbackURL = `/oauth/authorize?${searchParams.toString()}`;
|
||||
|
||||
const handleSignIn = async (provider: "google" | "github") => {
|
||||
setLoadingProvider(provider);
|
||||
try {
|
||||
await authClient.signIn.social({ provider, callbackURL });
|
||||
} catch {
|
||||
setLoadingProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = loadingProvider !== null;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="rounded-md border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPending || redirecting || !appName) {
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center bg-background">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">Sign in to</p>
|
||||
<h1 className="mt-1 text-lg font-semibold text-foreground">
|
||||
{appName}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleSignIn("google")}
|
||||
className="h-10 w-full justify-center gap-2"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Google className="h-4 w-4" />
|
||||
Continue with Google
|
||||
{loadingProvider === "google" && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleSignIn("github")}
|
||||
className="h-10 w-full justify-center gap-2"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
Continue with GitHub
|
||||
{loadingProvider === "github" && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{scopes.length > 0 && (
|
||||
<div className="mt-6 border-t pt-4">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Permissions requested
|
||||
</p>
|
||||
<ul className="space-y-1.5 text-sm text-muted-foreground">
|
||||
{scopes.map((scope) => (
|
||||
<li key={scope}>{SCOPE_LABELS[scope] ?? scope}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-center text-xs text-muted-foreground/60">
|
||||
Authorizing will grant {appName} access to the permissions above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
# OAuth 2.0 API
|
||||
|
||||
tweakcn exposes an OAuth 2.0 API so external apps can authenticate users and access their data.
|
||||
|
||||
## Registering an app
|
||||
|
||||
Register an OAuth app via the CLI script:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-oauth-app.ts \
|
||||
--name "My App" \
|
||||
--redirect-uris "https://myapp.com/callback" \
|
||||
--scopes "themes:read,profile:read" \
|
||||
--description "Optional description"
|
||||
```
|
||||
|
||||
This outputs a `client_id` and `client_secret`. The secret is shown once and cannot be retrieved later.
|
||||
|
||||
## Authorization flow
|
||||
|
||||
Standard OAuth 2.0 Authorization Code flow. PKCE is supported but optional.
|
||||
|
||||
### 1. Redirect the user to authorize
|
||||
|
||||
```
|
||||
GET https://tweakcn.com/api/oauth/authorize
|
||||
?client_id=CLIENT_ID
|
||||
&redirect_uri=https://myapp.com/callback
|
||||
&response_type=code
|
||||
&scope=themes:read profile:read
|
||||
&state=RANDOM_STRING
|
||||
```
|
||||
|
||||
If the user is not signed in, they'll be shown a sign-in page. After signing in, they're redirected to your `redirect_uri` with an authorization code:
|
||||
|
||||
```
|
||||
https://myapp.com/callback?code=AUTH_CODE&state=RANDOM_STRING
|
||||
```
|
||||
|
||||
### 2. Exchange the code for tokens
|
||||
|
||||
```bash
|
||||
curl -X POST https://tweakcn.com/api/oauth/token \
|
||||
-d grant_type=authorization_code \
|
||||
-d client_id=CLIENT_ID \
|
||||
-d client_secret=CLIENT_SECRET \
|
||||
-d code=AUTH_CODE \
|
||||
-d redirect_uri=https://myapp.com/callback
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "...",
|
||||
"refresh_token": "...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "themes:read profile:read"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Call the API
|
||||
|
||||
Pass the access token as a Bearer token:
|
||||
|
||||
```bash
|
||||
curl https://tweakcn.com/api/v1/themes \
|
||||
-H "Authorization: Bearer ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
### 4. Refresh tokens
|
||||
|
||||
Access tokens expire after 1 hour. Use the refresh token to get a new pair:
|
||||
|
||||
```bash
|
||||
curl -X POST https://tweakcn.com/api/oauth/token \
|
||||
-d grant_type=refresh_token \
|
||||
-d client_id=CLIENT_ID \
|
||||
-d client_secret=CLIENT_SECRET \
|
||||
-d refresh_token=REFRESH_TOKEN
|
||||
```
|
||||
|
||||
### 5. Revoke tokens
|
||||
|
||||
```bash
|
||||
curl -X POST https://tweakcn.com/api/oauth/revoke \
|
||||
-d token=ACCESS_OR_REFRESH_TOKEN
|
||||
```
|
||||
|
||||
## Using with Better Auth's genericOAuth
|
||||
|
||||
tweakcn works as a provider with Better Auth's `genericOAuth` plugin:
|
||||
|
||||
```typescript
|
||||
// server
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "tweakcn",
|
||||
clientId: process.env.TWEAKCN_CLIENT_ID,
|
||||
clientSecret: process.env.TWEAKCN_CLIENT_SECRET,
|
||||
authorizationUrl: "https://tweakcn.com/api/oauth/authorize",
|
||||
tokenUrl: "https://tweakcn.com/api/oauth/token",
|
||||
userInfoUrl: "https://tweakcn.com/api/oauth/userinfo",
|
||||
scopes: ["themes:read", "profile:read"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// client
|
||||
import { genericOAuthClient } from "better-auth/client/plugins";
|
||||
|
||||
const authClient = createAuthClient({
|
||||
plugins: [genericOAuthClient()],
|
||||
});
|
||||
|
||||
await authClient.signIn.oauth2({
|
||||
providerId: "tweakcn",
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
```
|
||||
|
||||
## API endpoints
|
||||
|
||||
All endpoints require `Authorization: Bearer <access_token>`.
|
||||
|
||||
### `GET /api/oauth/userinfo`
|
||||
|
||||
OIDC-compatible userinfo endpoint. Returns flat user fields. Requires `profile:read` scope.
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "user_123",
|
||||
"name": "Jane Doe",
|
||||
"email": "jane@example.com",
|
||||
"picture": "https://..."
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/me`
|
||||
|
||||
Returns the authenticated user's profile. Requires `profile:read` scope.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "...",
|
||||
"name": "Jane Doe",
|
||||
"email": "jane@example.com",
|
||||
"image": "https://..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/themes`
|
||||
|
||||
Returns all themes owned by the authenticated user. Requires `themes:read` scope.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "...",
|
||||
"name": "My Theme",
|
||||
"styles": { ... },
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/themes/:themeId`
|
||||
|
||||
Returns a single theme by ID. Only returns themes owned by the authenticated user. Requires `themes:read` scope.
|
||||
|
||||
## Scopes
|
||||
|
||||
| Scope | Description |
|
||||
|-------|-------------|
|
||||
| `themes:read` | Read the user's saved themes |
|
||||
| `profile:read` | Read the user's profile (name, email, avatar) |
|
||||
|
||||
## PKCE support
|
||||
|
||||
For public clients (e.g. SPAs, mobile apps), use PKCE by adding `code_challenge` and `code_challenge_method=S256` to the authorize request, then `code_verifier` when exchanging the code.
|
||||
|
||||
## Error responses
|
||||
|
||||
All error responses follow the OAuth 2.0 spec:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "invalid_token",
|
||||
"error_description": "Invalid or expired access token"
|
||||
}
|
||||
```
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import { db } from "@/db";
|
||||
import { oauthApp, oauthToken } from "@/db/schema";
|
||||
import {
|
||||
OAUTH_ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
OAUTH_REFRESH_TOKEN_EXPIRY_SECONDS,
|
||||
} from "@/lib/constants";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { randomBytes, createHash } from "crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import cuid from "cuid";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Token generation & hashing ---
|
||||
|
||||
export function generateSecureToken(): string {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
export async function hashSecret(secret: string): Promise<string> {
|
||||
return bcrypt.hash(secret, 10);
|
||||
}
|
||||
|
||||
export async function verifySecret(
|
||||
secret: string,
|
||||
hash: string
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(secret, hash);
|
||||
}
|
||||
|
||||
/** SHA-256 hash for fast token lookups (access/refresh tokens) */
|
||||
export function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
// --- Scopes ---
|
||||
|
||||
const VALID_SCOPES = ["themes:read", "profile:read"] as const;
|
||||
export type OAuthScope = (typeof VALID_SCOPES)[number];
|
||||
|
||||
export function validateScopes(scopes: string[]): scopes is OAuthScope[] {
|
||||
return scopes.every((s) => VALID_SCOPES.includes(s as OAuthScope));
|
||||
}
|
||||
|
||||
export function parseScopes(scopeString: string): string[] {
|
||||
return scopeString
|
||||
.split(/[\s,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// --- Redirect URI validation ---
|
||||
|
||||
export function validateRedirectUri(
|
||||
uri: string,
|
||||
registeredUris: string[]
|
||||
): boolean {
|
||||
return registeredUris.includes(uri);
|
||||
}
|
||||
|
||||
// --- PKCE ---
|
||||
|
||||
export function verifyCodeChallenge(
|
||||
codeVerifier: string,
|
||||
codeChallenge: string,
|
||||
method: string
|
||||
): boolean {
|
||||
if (method === "S256") {
|
||||
const hash = createHash("sha256")
|
||||
.update(codeVerifier)
|
||||
.digest("base64url");
|
||||
return hash === codeChallenge;
|
||||
}
|
||||
if (method === "plain") {
|
||||
return codeVerifier === codeChallenge;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Token creation ---
|
||||
|
||||
export async function createTokenPair(
|
||||
appId: string,
|
||||
userId: string,
|
||||
scopes: string[]
|
||||
) {
|
||||
const accessToken = generateSecureToken();
|
||||
const refreshToken = generateSecureToken();
|
||||
const now = new Date();
|
||||
|
||||
const accessTokenExpiresAt = new Date(
|
||||
now.getTime() + OAUTH_ACCESS_TOKEN_EXPIRY_SECONDS * 1000
|
||||
);
|
||||
const refreshTokenExpiresAt = new Date(
|
||||
now.getTime() + OAUTH_REFRESH_TOKEN_EXPIRY_SECONDS * 1000
|
||||
);
|
||||
|
||||
await db.insert(oauthToken).values({
|
||||
id: cuid(),
|
||||
accessTokenHash: hashToken(accessToken),
|
||||
refreshTokenHash: hashToken(refreshToken),
|
||||
appId,
|
||||
userId,
|
||||
scopes,
|
||||
accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
token_type: "Bearer" as const,
|
||||
expires_in: OAUTH_ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
scope: scopes.join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Bearer token resolution ---
|
||||
|
||||
export async function resolveUserFromBearerToken(
|
||||
authHeader: string | null
|
||||
): Promise<{ userId: string; scopes: string[] } | null> {
|
||||
if (!authHeader?.startsWith("Bearer ")) return null;
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
const [record] = await db
|
||||
.select({
|
||||
userId: oauthToken.userId,
|
||||
scopes: oauthToken.scopes,
|
||||
accessTokenExpiresAt: oauthToken.accessTokenExpiresAt,
|
||||
})
|
||||
.from(oauthToken)
|
||||
.where(
|
||||
and(eq(oauthToken.accessTokenHash, tokenHash), isNull(oauthToken.revokedAt))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!record) return null;
|
||||
if (new Date() > record.accessTokenExpiresAt) return null;
|
||||
|
||||
return { userId: record.userId, scopes: record.scopes };
|
||||
}
|
||||
|
||||
export function requireScope(scopes: string[], required: OAuthScope): boolean {
|
||||
return scopes.includes(required);
|
||||
}
|
||||
|
||||
export async function requireAuth(
|
||||
req: NextRequest,
|
||||
scope: OAuthScope
|
||||
): Promise<
|
||||
| { tokenData: { userId: string; scopes: string[] }; error: null }
|
||||
| { tokenData: null; error: Response }
|
||||
> {
|
||||
const tokenData = await resolveUserFromBearerToken(
|
||||
req.headers.get("authorization")
|
||||
);
|
||||
|
||||
if (!tokenData) {
|
||||
return {
|
||||
tokenData: null,
|
||||
error: oauthError("invalid_token", "Invalid or expired access token", 401),
|
||||
};
|
||||
}
|
||||
|
||||
if (!requireScope(tokenData.scopes, scope)) {
|
||||
return {
|
||||
tokenData: null,
|
||||
error: oauthError("insufficient_scope", `Requires ${scope} scope`, 403),
|
||||
};
|
||||
}
|
||||
|
||||
return { tokenData, error: null };
|
||||
}
|
||||
|
||||
// --- Client authentication ---
|
||||
|
||||
export async function authenticateClient(
|
||||
clientId: string,
|
||||
clientSecret: string
|
||||
) {
|
||||
const [app] = await db
|
||||
.select({
|
||||
id: oauthApp.id,
|
||||
clientSecretHash: oauthApp.clientSecretHash,
|
||||
})
|
||||
.from(oauthApp)
|
||||
.where(and(eq(oauthApp.clientId, clientId), eq(oauthApp.isActive, true)))
|
||||
.limit(1);
|
||||
|
||||
if (!app) return null;
|
||||
|
||||
const valid = await verifySecret(clientSecret, app.clientSecretHash);
|
||||
if (!valid) return null;
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// --- JSON error responses ---
|
||||
|
||||
export function oauthError(
|
||||
error: string,
|
||||
description: string,
|
||||
status: number = 400
|
||||
) {
|
||||
return Response.json({ error, error_description: description }, { status });
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
"@vercel/kv": "^3.0.0",
|
||||
"@vercel/og": "^0.6.8",
|
||||
"ai": "^5.0.28",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "^1.2.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Generated
+9
@@ -89,6 +89,9 @@ importers:
|
||||
ai:
|
||||
specifier: ^5.0.28
|
||||
version: 5.0.28(zod@3.25.76)
|
||||
bcryptjs:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
better-auth:
|
||||
specifier: ^1.2.7
|
||||
version: 1.2.7
|
||||
@@ -4130,6 +4133,10 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
bcryptjs@3.0.3:
|
||||
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
|
||||
hasBin: true
|
||||
|
||||
better-auth@1.2.7:
|
||||
resolution: {integrity: sha512-2hCB263GSrgetsMUZw8vv9O1e4S4AlYJW3P4e8bX9u3Q3idv4u9BzDFCblpTLuL4YjYovghMCN0vurAsctXOAQ==}
|
||||
|
||||
@@ -12088,6 +12095,8 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.0: {}
|
||||
|
||||
bcryptjs@3.0.3: {}
|
||||
|
||||
better-auth@1.2.7:
|
||||
dependencies:
|
||||
'@better-auth/utils': 0.2.4
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Create an OAuth app for external integrations.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/create-oauth-app.ts \
|
||||
* --name "My App" \
|
||||
* --redirect-uris "http://localhost:3000/callback,https://myapp.com/callback" \
|
||||
* [--scopes "themes:read,profile:read"] \
|
||||
* [--description "My cool app"]
|
||||
*/
|
||||
|
||||
import { neon } from "@neondatabase/serverless";
|
||||
import { drizzle } from "drizzle-orm/neon-http";
|
||||
import { oauthApp } from "../db/schema";
|
||||
import { generateSecureToken, hashSecret } from "../lib/oauth";
|
||||
import { randomBytes } from "crypto";
|
||||
import cuid from "cuid";
|
||||
import { config } from "dotenv";
|
||||
|
||||
config({ path: ".env.local" });
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const parsed: Record<string, string> = {};
|
||||
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
const key = args[i].replace(/^--/, "");
|
||||
parsed[key] = args[i + 1];
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
|
||||
if (!args.name || !args["redirect-uris"]) {
|
||||
console.error(
|
||||
"Usage: npx tsx scripts/create-oauth-app.ts --name <name> --redirect-uris <uri1,uri2> [--scopes <scope1,scope2>] [--description <desc>]"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error("DATABASE_URL not set. Make sure .env.local exists.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = neon(process.env.DATABASE_URL);
|
||||
const db = drizzle({ client: sql });
|
||||
|
||||
const clientId = randomBytes(16).toString("hex");
|
||||
const clientSecret = generateSecureToken();
|
||||
const clientSecretHash = await hashSecret(clientSecret);
|
||||
|
||||
const redirectUris = args["redirect-uris"].split(",").map((u) => u.trim());
|
||||
const scopes = args.scopes
|
||||
? args.scopes.split(",").map((s) => s.trim())
|
||||
: ["themes:read", "profile:read"];
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(oauthApp).values({
|
||||
id: cuid(),
|
||||
name: args.name,
|
||||
description: args.description ?? null,
|
||||
clientId,
|
||||
clientSecretHash,
|
||||
redirectUris,
|
||||
scopes,
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
console.log("\nOAuth app created successfully!\n");
|
||||
console.log(" Name: ", args.name);
|
||||
console.log(" Client ID: ", clientId);
|
||||
console.log(" Client Secret: ", clientSecret);
|
||||
console.log(" Redirect URIs: ", redirectUris.join(", "));
|
||||
console.log(" Scopes: ", scopes.join(", "));
|
||||
console.log(
|
||||
"\n ⚠️ Save the client secret now — it cannot be retrieved later.\n"
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Failed to create OAuth app:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user