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:
Sahaj Jain
2026-03-06 22:05:20 +05:30
committed by GitHub
parent 4206d094d0
commit bb8df47a8c
14 changed files with 1138 additions and 0 deletions
+25
View File
@@ -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 });
}
+101
View File
@@ -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);
}
+41
View File
@@ -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 });
}
+180
View File
@@ -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);
}
+46
View File
@@ -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,
});
}
+27
View File
@@ -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 });
}
+35
View File
@@ -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 });
}
+23
View File
@@ -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 });
}
+144
View File
@@ -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>
);
}
+206
View File
@@ -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
View File
@@ -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 });
}
+1
View File
@@ -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",
+9
View File
@@ -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
+90
View File
@@ -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);
});