From bb8df47a8c02cfbd29b64c2a57fb01167372df7b Mon Sep 17 00:00:00 2001 From: Sahaj Jain <82111591+jnsahaj@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:05:20 +0530 Subject: [PATCH] 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 * 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 * replace demo app with OAuth API documentation Co-Authored-By: Claude Opus 4.6 * 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 --------- Co-authored-by: Claude Opus 4.6 --- app/api/oauth/app-info/route.ts | 25 ++++ app/api/oauth/authorize/route.ts | 101 +++++++++++++ app/api/oauth/revoke/route.ts | 41 ++++++ app/api/oauth/token/route.ts | 180 +++++++++++++++++++++++ app/api/oauth/userinfo/route.ts | 46 ++++++ app/api/v1/me/route.ts | 27 ++++ app/api/v1/themes/[themeId]/route.ts | 35 +++++ app/api/v1/themes/route.ts | 23 +++ app/oauth/authorize/page.tsx | 144 ++++++++++++++++++ docs/oauth-api.md | 206 ++++++++++++++++++++++++++ lib/oauth.ts | 210 +++++++++++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 9 ++ scripts/create-oauth-app.ts | 90 ++++++++++++ 14 files changed, 1138 insertions(+) create mode 100644 app/api/oauth/app-info/route.ts create mode 100644 app/api/oauth/authorize/route.ts create mode 100644 app/api/oauth/revoke/route.ts create mode 100644 app/api/oauth/token/route.ts create mode 100644 app/api/oauth/userinfo/route.ts create mode 100644 app/api/v1/me/route.ts create mode 100644 app/api/v1/themes/[themeId]/route.ts create mode 100644 app/api/v1/themes/route.ts create mode 100644 app/oauth/authorize/page.tsx create mode 100644 docs/oauth-api.md create mode 100644 lib/oauth.ts create mode 100644 scripts/create-oauth-app.ts diff --git a/app/api/oauth/app-info/route.ts b/app/api/oauth/app-info/route.ts new file mode 100644 index 00000000..6390d5e2 --- /dev/null +++ b/app/api/oauth/app-info/route.ts @@ -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 }); +} diff --git a/app/api/oauth/authorize/route.ts b/app/api/oauth/authorize/route.ts new file mode 100644 index 00000000..ba9c8dd5 --- /dev/null +++ b/app/api/oauth/authorize/route.ts @@ -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); +} diff --git a/app/api/oauth/revoke/route.ts b/app/api/oauth/revoke/route.ts new file mode 100644 index 00000000..abde91ad --- /dev/null +++ b/app/api/oauth/revoke/route.ts @@ -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 }); +} diff --git a/app/api/oauth/token/route.ts b/app/api/oauth/token/route.ts new file mode 100644 index 00000000..82fca569 --- /dev/null +++ b/app/api/oauth/token/route.ts @@ -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); +} diff --git a/app/api/oauth/userinfo/route.ts b/app/api/oauth/userinfo/route.ts new file mode 100644 index 00000000..2c982a88 --- /dev/null +++ b/app/api/oauth/userinfo/route.ts @@ -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, + }); +} diff --git a/app/api/v1/me/route.ts b/app/api/v1/me/route.ts new file mode 100644 index 00000000..5c2cf5f8 --- /dev/null +++ b/app/api/v1/me/route.ts @@ -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 }); +} diff --git a/app/api/v1/themes/[themeId]/route.ts b/app/api/v1/themes/[themeId]/route.ts new file mode 100644 index 00000000..92ebd672 --- /dev/null +++ b/app/api/v1/themes/[themeId]/route.ts @@ -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 }); +} diff --git a/app/api/v1/themes/route.ts b/app/api/v1/themes/route.ts new file mode 100644 index 00000000..c07bc24d --- /dev/null +++ b/app/api/v1/themes/route.ts @@ -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 }); +} diff --git a/app/oauth/authorize/page.tsx b/app/oauth/authorize/page.tsx new file mode 100644 index 00000000..615ee876 --- /dev/null +++ b/app/oauth/authorize/page.tsx @@ -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 = { + "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(null); + const [error, setError] = useState(null); + const [appName, setAppName] = useState(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 ( +
+
+
+ {error} +
+
+
+ ); + } + + if (isPending || redirecting || !appName) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

Sign in to

+

+ {appName} +

+
+ +
+ + + +
+ + {scopes.length > 0 && ( +
+

+ Permissions requested +

+
    + {scopes.map((scope) => ( +
  • {SCOPE_LABELS[scope] ?? scope}
  • + ))} +
+
+ )} + +

+ Authorizing will grant {appName} access to the permissions above. +

+
+
+ ); +} diff --git a/docs/oauth-api.md b/docs/oauth-api.md new file mode 100644 index 00000000..5f2206ac --- /dev/null +++ b/docs/oauth-api.md @@ -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 `. + +### `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" +} +``` diff --git a/lib/oauth.ts b/lib/oauth.ts new file mode 100644 index 00000000..02f094a8 --- /dev/null +++ b/lib/oauth.ts @@ -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 { + return bcrypt.hash(secret, 10); +} + +export async function verifySecret( + secret: string, + hash: string +): Promise { + 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 }); +} diff --git a/package.json b/package.json index 0c85daf7..bab8fdba 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f4e2a5a..d46213a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/create-oauth-app.ts b/scripts/create-oauth-app.ts new file mode 100644 index 00000000..88be0dd3 --- /dev/null +++ b/scripts/create-oauth-app.ts @@ -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 = {}; + + 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 --redirect-uris [--scopes ] [--description ]" + ); + 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); +});