Files
Sahaj Jain bb8df47a8c 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>
2026-03-06 22:05:20 +05:30

4.5 KiB

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:

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

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:

{
  "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:

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:

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

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:

// 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"],
        },
      ],
    }),
  ],
});
// 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.

{
  "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.

{
  "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.

{
  "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:

{
  "error": "invalid_token",
  "error_description": "Invalid or expired access token"
}