tcm-oauth

Use when integrating the @crimsoncorp/oauth-react SDK (The Crimson Market / Portal.Service OAuth) into a React or Next.js app. Covers OAuth client registration, callback pages, server-side token exchange, session adapters, logout routes, popup vs redirect interaction modes, scopes (profile, email, subscriptions, listings, roles, gaming, relics, youtube, external_club), and external-club partner provisioning + webhook enrichment.

upvave/skills1 installsMITSynced Aug 26

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: tcm-oauth
description: Use when integrating the @crimsoncorp/oauth-react SDK (The Crimson Market / Portal.Service OAuth) into a React or Next.js app. Covers OAuth client registration, callback pages, server-side token exchange, session adapters, logout routes, popup vs redirect interaction modes, scopes (profile, email, subscriptions, listings, roles, gaming, relics, youtube, external_club), and external-club partner provisioning + webhook enrichment.
license: MIT
---

# tcm-oauth

Use this skill when adding, fixing, or migrating authentication in an
application that consumes `@crimsoncorp/oauth-react` against the Portal.Service
OAuth backend (a.k.a. TCM / The Crimson Market / Mana).

The supported topology is server-backed:

```txt
Browser -> @crimsoncorp/oauth-react -> App's server exchange route -> Portal.Service /oauth/token
```

Portal.Service still requires `client_secret` for token exchange, so a
fully browser-only integration is **not** supported. The app must own a server
route that can keep `TCM_OAUTH_CLIENT_SECRET` private.

Do not invent custom popup/redirect flows or call `/oauth/token` directly from
the browser. If the SDK does not expose a needed capability, call that out
rather than duplicating exchange or session logic in the app.

## Install

```bash
npm install @crimsoncorp/oauth-react
```

Optional bundled styles:

```ts
import "@crimsoncorp/oauth-react/styles.css";
```

For production apps, pin to an exact version.

## Prerequisites

Before writing any integration code:

1. A **callback route** on the app origin. Recommended path: `/auth/tcm/callback`.
2. A **server exchange endpoint**. Default route-backed path: `/api/auth/tcm/oauth-exchange`.
3. A client registered in the Developers portal with **exact** callback URLs
   and a server-kept client secret.
4. A server-backed app runtime (Next.js route handler, Express, etc.) that can
   hold `client_secret` privately.

Register clients and redirect URIs in OAuth App Setup. URI matching is exact,
so local, staging, and production callbacks must each be registered.

## Environment

OAuth endpoints are relative to the Mana API base URL.

| Environment | Web URL                                  | API URL                                       |
| ----------- | ---------------------------------------- | --------------------------------------------- |
| Development | `https://dev.portal.raum.au`             | `https://dev.portal.raum.au/mana`             |
| Production  | `https://www.thecrimsonmarket.com`       | `https://www.thecrimsonmarket.com/mana`       |

```env
NEXT_PUBLIC_TCM_OAUTH_CLIENT_ID=tcm_xxx
NEXT_PUBLIC_TCM_OAUTH_WEB_URL=https://www.thecrimsonmarket.com
TCM_OAUTH_API_URL=https://www.thecrimsonmarket.com/mana
TCM_OAUTH_CLIENT_ID=tcm_xxx
TCM_OAUTH_CLIENT_SECRET=your-secret
TCM_OAUTH_REDIRECT_URI=https://your-app.example.com/auth/tcm/callback
```

`TCM_OAUTH_CLIENT_SECRET` is server-only. It must never appear in
`NEXT_PUBLIC_*`, client components, React props, browser bundles, logs, error
responses, screenshots, or test snapshots. If a client secret is rotated,
update the server route immediately before testing logins.

For `external_club` clients, the same secret is also used to sign optional
webhook enrichment callbacks.

## Choosing an Integration Surface

| Use case                                              | Surface                                                                 |
| ----------------------------------------------------- | ----------------------------------------------------------------------- |
| Standard server-backed React or Next.js app           | `useTcmOAuth` + `TcmOAuthCallbackPage` + `createTcmOAuthExchangeRoute`  |
| Preserving an older popup-only flow                   | `useTcmOAuthPopupRoute` (compatibility; default `/auth/tcm/popup-callback`) |
| Custom exchange handling / non-Next.js servers        | `useTcmOAuthPopup`, `createTcmOAuthClient`, `createTcmOAuthRouteClient` |

The first row is the recommended path for new integrations. The lower-level
APIs exist for migration and edge cases — prefer the high-level path unless
the user has a specific reason to drop down.

## Recommended React Hook: `useTcmOAuth`

`useTcmOAuth` lets the SDK pick popup vs redirect automatically and falls back
to redirect if popup opening is blocked.

```tsx
import { useTcmOAuth } from "@crimsoncorp/oauth-react";

export function GoogleLogin() {
  const oauth = useTcmOAuth<{ userId: string }>({
    clientId: process.env.NEXT_PUBLIC_TCM_OAUTH_CLIENT_ID!,
    tcmWebUrl: process.env.NEXT_PUBLIC_TCM_OAUTH_WEB_URL!,
    exchangeEndpoint: "/api/auth/tcm/oauth-exchange",
    callbackPath: "/auth/tcm/callback",
    scope: "profile email",
    interactionMode: "auto",
    onSuccess: ({ userId }) => {
      console.log("Logged in", userId);
    },
    onError: (error) => {
      console.error(error.code, error.message);
    },
  });

  return (
    <button onClick={() => void oauth.startLogin("google")} disabled={oauth.authenticating}>
      {oauth.authenticating ? "Please wait..." : "Sign in with Google"}
    </button>
  );
}
```

### Hook return shape

- `authenticating` — boolean; true while a flow is in progress.
- `phase` — current step of the flow.
- `error` — last error, or null.
- `resolvedInteractionMode` — `"popup" | "redirect"` once the SDK has decided.
- `startLogin(provider?)` — kicks off the flow.
- `clearError()` — resets the error state.

### Interaction modes

```ts
interactionMode: "auto" | "popup" | "redirect"
```

- `"auto"` (recommended): popup on desktop-like environments, redirect on
  mobile-like environments, with automatic redirect fallback if the popup is
  blocked.
- Disable popup-blocked fallback with `fallbackToRedirect: false`.
- Specify a post-login landing route for redirect flows with `returnTo: "/account"`.

### Diagnostics

Route-backed exchange requests send `x-tcm-flow-id` and `x-tcm-message-id`
correlation headers in development and staging by default. Override with:

```ts
diagnostics: "always" | "never"
```

### Callback page

Render `TcmOAuthCallbackPage` on the app origin at the registered callback
path:

```tsx
import { TcmOAuthCallbackPage } from "@crimsoncorp/oauth-react";

export default function Page() {
  return <TcmOAuthCallbackPage />;
}
```

Recommended path: `/auth/tcm/callback`. The callback handler stores the
result and resumes at the initiating route via `returnTo` when redirect mode
is used.

## Next.js App Router (Recommended)

This is the production-recommended integration for `@crimsoncorp/oauth-react`.

### 1. Callback page

```tsx
// app/auth/tcm/callback/page.tsx
import { TcmOAuthCallbackPage } from "@crimsoncorp/oauth-react";

export default function Page() {
  return <TcmOAuthCallbackPage />;
}
```

### 2. Exchange route + session adapter

```ts
// app/api/auth/tcm/oauth-exchange/route.ts
import {
  createTcmCookieSessionAdapter,
  resolveTcmAuthSession,
} from "@crimsoncorp/oauth-react/server";
import {
  createTcmLogoutRoute,
  createTcmOAuthExchangeRoute,
} from "@crimsoncorp/oauth-react/nextjs";

const sessionAdapter = createTcmCookieSessionAdapter({
  appId: "my-app",
  maxAgeSeconds: 60 * 60 * 24,
});

const route = createTcmOAuthExchangeRoute({
  oauth: {
    apiBaseUrl: process.env.TCM_OAUTH_API_URL!,
    clientId: process.env.TCM_OAUTH_CLIENT_ID!,
    clientSecret: process.env.TCM_OAUTH_CLIENT_SECRET!,
    callbackPath: "/auth/tcm/callback",
    redirectUri: process.env.TCM_OAUTH_REDIRECT_URI,
    googleOnly: true,
  },
  async onResolvedUser({ userInfo, traceId }) {
    if (!userInfo.googleId) {
      return {
        status: 400,
        body: { message: "Missing googleId", traceId },
      };
    }

    const user = await upsertUserFromTcm(userInfo);

    return {
      body: { userId: user.id, email: user.email },
      session: { id: user.id },
    };
  },
  applySession(response, session) {
    sessionAdapter.apply(response, session.id);
  },
});

export const { POST } = route;
```

`createTcmOAuthExchangeRoute` owns:

- payload validation
- redirect URI resolution and retry
- token exchange
- userinfo fetch
- correlation header forwarding
- duplicate-request single-flight handling

The app still owns:

- user creation or lookup (`onResolvedUser`)
- session payload signing/verification
- response body shape

### 3. Runtime auth resolution

Mixed-mode apps should resolve auth per request, not at build time.

```ts
function readStandaloneSession(request: Request) {
  const cookieValue = sessionAdapter.read(request);
  return cookieValue ? { sub: cookieValue } : null;
}

function readParentAuthToken(request: Request) {
  const hasParentCookie = request.headers.get("cookie")?.includes("authToken=");
  return hasParentCookie ? { sub: "host-user-id" } : null;
}

export function getSessionFromRequest(request: Request) {
  return resolveTcmAuthSession(request, {
    sources: [
      { name: "sdk_session", resolve: readStandaloneSession },
      { name: "parent_auth_token", resolve: readParentAuthToken },
    ],
    precedence: ["sdk_session", "parent_auth_token"],
  });
}
```

Guidance:

- **Standalone apps**: only `sdk_session`.
- **Embedded shared-domain apps**: keep host-owned `authToken`.
- **Mixed-mode apps**: resolve `sdk_session` first, fall back to `parent_auth_token`.

### 4. Logout route

```ts
const logoutRoute = createTcmLogoutRoute({
  resolveSession: getSessionFromRequest,
  standaloneSessionAdapter: sessionAdapter,
  onSharedCookieLogout() {
    return Response.json({
      success: true,
      authSource: "parent_auth_token",
      delegated: true,
    });
  },
});

export const { GET, POST } = logoutRoute;
```

Behavior:

- `sdk_session` clears the SDK-managed standalone cookie.
- `parent_auth_token` delegates to host/platform logout and does **not** clear
  the standalone cookie.

### 5. Browser hook

```tsx
import { useTcmOAuth } from "@crimsoncorp/oauth-react";

export function LoginButton() {
  const oauth = useTcmOAuth<{ userId: string }>({
    clientId: process.env.NEXT_PUBLIC_TCM_OAUTH_CLIENT_ID!,
    tcmWebUrl: process.env.NEXT_PUBLIC_TCM_OAUTH_WEB_URL!,
    exchangeEndpoint: "/api/auth/tcm/oauth-exchange",
    callbackPath: "/auth/tcm/callback",
    interactionMode: "auto",
    googleOnly: true,
  });

  return (
    <button onClick={() => void oauth.startLogin()} disabled={oauth.authenticating}>
      {oauth.authenticating ? "Signing in..." : "Continue with Google"}
    </button>
  );
}
```

## Popup Contract (Google-only flows)

For Google-only popup flows, the SDK standardizes the authorize URL down to:

- OAuth protocol params
- `ui_mode=popup`
- `required_provider=google`

UI-specific popup params (`provider`, `auto_start_provider`, `popup_variant`)
are no longer part of the standard SDK contract. `service.core-ui` is expected
to:

- show its own minimal spinner shell for popup login
- silently preflight `/oauth/authorize`
- redirect the popup window itself to Google when the backend returns 401 or
  `provider_link_required`
- show consent only when the backend returns a consent payload

## Registering OAuth Applications

Workflow when setting up a new client:

1. Create the client in **Create Application** in the developer portal.
2. Register the **exact** callback URI the app will use, including protocol,
   hostname, and path. The recommended path is `/auth/tcm/callback`.
3. Keep the generated client secret on the server only.
4. Select the minimum scopes the integration needs (always include `profile`).
5. Match the configured callback route with the SDK callback component and
   exchange route.

Manage existing clients from **My Applications** in the portal.

## Scopes

Request only the scopes the integration needs. `profile` is required for
every client.

| Scope            | Returns                                                                                                               |
| ---------------- | --------------------------------------------------------------------------------------------------------------------- |
| `profile` (req.) | `userName`, `_id`, `firstName`, `lastName`, `displayName`, `avatarUrl`, `googleId`, `youtubeChannelIds`, `primaryYouTubeChannelId` |
| `email`          | `email`                                                                                                               |
| `subscriptions`  | `club`, `customerId`                                                                                                  |
| `listings`       | `listings`                                                                                                            |
| `roles`          | `roles`                                                                                                               |
| `gaming`         | `battlenetId`, `discordId`, `steamId`, `twitchId`                                                                     |
| `relics`         | `relics`                                                                                                              |
| `youtube`        | `youtubeChannelIds`, `primaryYouTubeChannelId`                                                                        |
| `external_club`  | Enables partner provisioning + webhook enrichment (no fixed userinfo fields by itself).                                |

The route-backed flow uses the explicit scope requested by the app, or the
single allowed client scope when only one scope is configured.

## External Club Integration

When a client has the `external_club` scope, partners can provision or link
users via:

```
POST /:uiName/external-club/user
```

Base URL should not include the tenant path; `uiName` is part of the route.

```env
EXTERNAL_CLUB_BASE_URL=https://www.thecrimsonmarket.com
EXTERNAL_CLUB_UI_NAME=mana
```

Resulting endpoint: `https://www.thecrimsonmarket.com/mana/external-club/user`.

### Required fields

`userName`, `email`, `clubName`, `clubUserId`, `hubClubId`, `clientId`,
`clientSecret`, `encryptedPassword`.

`encryptedPassword` **must** be a bcrypt hash. Portal.Service rejects
non-bcrypt values.

### Optional fields

`webhookEventEndpoint`.

### Example payload

```json
{
  "userName": "charades_diablo4traders",
  "email": "socials@diablo4traders.com",
  "encryptedPassword": "$2b$10$.................................................",
  "clubName": "charadescollective",
  "clubUserId": "113769124091223507890",
  "hubClubId": "11735",
  "clientId": "tcm_xxx",
  "clientSecret": "your-oauth-client-secret",
  "webhookEventEndpoint": "https://pokecharades.example.com/api/v1/public/external-club/user-context"
}
```

### Validation and behavior

- OAuth client credentials are validated before user provisioning starts.
- The client must include `external_club` in `allowedScopes`.
- Existing users are matched by partner mapping (`clubName` + `clubUserId`)
  or by normalized email.
- Portal.Service persists external-club metadata, the OAuth client id, and
  the stored password hash on the linked user.
- The response includes the resolved `hubUserId`, `hubClubId`, `oauthClientId`,
  and the stored password hash.

### Webhook enrichment on `/oauth/userinfo`

When a linked external-club user later authenticates with OAuth, Portal.Service
can append partner-owned data under `externalClubContext` in
`/oauth/userinfo`. The enrichment call is optional — if no
`webhookEventEndpoint` is stored or the callback fails, normal OAuth login
still succeeds.

The enrichment callback is signed with the OAuth client secret stored for the
linked user:

```
POST https://pokecharades.example.com/api/v1/public/external-club/user-context
x-external-club-client-id: tcm_xxx
x-external-club-timestamp: 1741702982
x-external-club-signature: <hmac_sha256_hex>

{
  "clubName": "charadescollective",
  "externalUserId": "113769124091223507890"
}
```

Resulting `/oauth/userinfo` shape:

```json
{
  "sub": "67d0...",
  "tcmid": "11735",
  "email": "socials@diablo4traders.com",
  "externalClubContext": {
    "clubName": "charadescollective",
    "externalUserId": "113769124091223507890",
    "externalClubUser": {
      "isSubscribed": true,
      "tierName": "Gold",
      "billingCycle": "monthly"
    }
  }
}
```

The webhook receives only partner lookup context and must return only
partner-owned data. The `external_club` scope does not add fixed userinfo
fields by itself; it enables the provisioning + enrichment flow.

## Low-level / Advanced APIs

Drop down to these only when the recommended path doesn't fit:

- `useTcmOAuthPopup` — manual popup management; supply `exchangeCode` yourself.
- `useTcmOAuthPopupRoute` — compatibility route-backed popup-only path; default
  callback `/auth/tcm/popup-callback`.
- `createTcmOAuthClient`, `createTcmOAuthRouteClient` — client factories for
  custom flows.
- Server helpers under `@crimsoncorp/oauth-react/server` — `createTcmCookieSessionAdapter`,
  `resolveTcmAuthSession`.
- Next.js helpers under `@crimsoncorp/oauth-react/nextjs` —
  `createTcmOAuthExchangeRoute`, `createTcmLogoutRoute`.

## Operational Notes

- Redirect URI matching is exact. Register local, staging, and production
  callbacks separately.
- Keep the callback page same-origin with the opener (popup flows require this).
- Never expose `TCM_OAUTH_CLIENT_SECRET` to the browser.
- Use `createTcmCookieSessionAdapter` so the SDK owns standalone session
  cookie naming and clearing.
- Embedded shared-domain `authToken` remains host-owned and is **not** replaced
  by the SDK cookie adapter.
- Popup-specific exports still exist for compatibility, but the neutral
  `useTcmOAuth` + `TcmOAuthCallbackPage` path is the recommended production
  integration.
- If a client uses `external_club`, the same client secret signs both
  provisioning requests and webhook enrichment callbacks. Rotate carefully.

## Implementation Checklist

1. Confirm the app framework, router, and package manager.
2. Register the OAuth client in the portal with exact callback URLs and the
   minimum scopes (always include `profile`).
3. Set server env vars (`TCM_OAUTH_API_URL`, `TCM_OAUTH_CLIENT_ID`,
   `TCM_OAUTH_CLIENT_SECRET`, `TCM_OAUTH_REDIRECT_URI`) and public browser
   config (`NEXT_PUBLIC_TCM_OAUTH_CLIENT_ID`, `NEXT_PUBLIC_TCM_OAUTH_WEB_URL`)
   without exposing the client secret.
4. Mount `TcmOAuthCallbackPage` at the registered callback path.
5. Add the server exchange route via `createTcmOAuthExchangeRoute` and own
   user upsert in `onResolvedUser`.
6. Wire `createTcmCookieSessionAdapter` and `resolveTcmAuthSession` for
   session reads; add `createTcmLogoutRoute` for logout.
7. Use `useTcmOAuth` with `interactionMode: "auto"` in browser components.
8. For `external_club` clients, document the bcrypt password requirement,
   verify the webhook signature on the partner side, and never log the
   raw client secret.
9. Add narrow tests for the protected exchange route, callback page, and
   logout behavior.

## Verification

Run the target app's normal checks after integration, e.g.:

```bash
npm run lint
npm run typecheck
npm run test
```

Manually verify:

- Login on desktop opens a popup; on mobile (or with popups blocked) it
  redirects.
- Callback at the registered path completes and lands at `returnTo` when set.
- Logout clears the SDK session cookie for `sdk_session` flows and delegates
  for `parent_auth_token` flows.
- `/oauth/userinfo` (server-side, after exchange) returns the expected
  scope-bound fields.

More API Design skills

← All API Design skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY