# Auth and team APIs

> Reference: Login, OAuth callbacks, JWT refresh, account links, team membership, invites, permissions, billing, and user endpoints.

- Repository: macro-inc/macro
- GitHub: https://github.com/macro-inc/macro
- Human docs: https://grok-wiki.com/public/docs/macro-inc-macro-bb988e1a448e
- Complete Markdown: https://grok-wiki.com/public/docs/macro-inc-macro-bb988e1a448e/llms-full.txt

## Source Files

- `js/app/packages/service-clients/service-auth/openapi.json`
- `js/app/packages/service-clients/service-auth/client.ts`
- `rust/cloud-storage/authentication_service/src/openapi.rs`
- `rust/cloud-storage/authentication_service/src/config.rs`
- `js/app/packages/core/auth/sso.ts`
- `js/app/packages/core/auth/logout.ts`

---

---
title: "Auth and team APIs"
description: "Reference: Login, OAuth callbacks, JWT refresh, account links, team membership, invites, permissions, billing, and user endpoints."
---

The auth API is an Axum service exposed through `SERVER_HOSTS['auth-service']`; the frontend wraps it with `authServiceClient`, uses cookie credentials by default, and can switch to bearer-token requests with `ENABLE_BEARER_TOKEN_AUTH`.

## Runtime surface

| Surface | Runtime behavior |
| --- | --- |
| Base URL | `SERVER_HOSTS['auth-service']` (`http://localhost:8080` when `VITE_LOCAL_SERVERS` selects local auth) |
| Frontend client | `js/app/packages/service-clients/service-auth/client.ts` |
| OpenAPI | Generated by `rust/cloud-storage/authentication_service/src/openapi.rs` from `swagger::ApiDoc` |
| Swagger UI | Mounted at `/docs`, spec at `/api-doc/openapi.json` |
| Auth transport | HttpOnly cookies, or `Authorization: Bearer ...` for selected token flows |
| Standard JSON error | `{ "message": string }` |
| Client result shape | `neverthrow` `Result<T, ResultError[]>` from `safeFetch` / `fetchWithAuth` |

<Note>
Some OpenAPI query parameters are marked `required: true` even when server code treats them as optional. Use the Rust handler behavior and the hand-written frontend client as the integration source of truth.
</Note>

## Login and session lifecycle

### Password login

:::endpoint POST /login/password Password login
Accepts:

```json
{
  "email": "user@example.com",
  "password": "password"
}
```

Returns `UserTokensResponse`:

```json
{
  "access_token": "jwt",
  "refresh_token": "refresh"
}
```

The server lowercases the email, validates email format, authenticates through FusionAuth, sets access and refresh cookies, and returns the same tokens in JSON. If FusionAuth reports an unregistered user, the handler registers the user from email and retries login. Unverified users receive `401`.
:::

### Passwordless login

:::endpoint POST /login/passwordless Start passwordless login
Accepts:

```json
{
  "email": "user@example.com",
  "redirect_uri": "https://macro.com/app/login",
  "referral_code": "optional"
}
```

Responses:

| Status | Meaning |
| --- | --- |
| `200` | Login code was generated, cached against the email, and sent. |
| `202` | The email belongs to an identity provider; response body is `{ "idp_id": string }`. |
| `400` | Invalid email or invalid request. |
| `403` | Blocked email. |
| `500` | FusionAuth, cache, or send failure. |

The callback endpoint is `GET /oauth/passwordless/{code}?email=...`. The frontend uses `disable_redirect=true` when it wants JSON tokens instead of a redirect.
:::

### SSO login

:::endpoint GET /login/sso Start SSO
Supported query parameters:

| Parameter | Required by server | Notes |
| --- | ---: | --- |
| `idp_name` | One of `idp_name` or `idp_id` | Identity provider display name, for example Google/Gmail. |
| `idp_id` | One of `idp_name` or `idp_id` | Direct FusionAuth identity provider ID. |
| `login_hint` | No | Pre-fills provider login when supported. |
| `original_url` | No | Web redirect target after successful login. |
| `is_mobile` | No | Causes callback to issue a session code instead of only cookies. |
| `referral_code` | No | Tracked after login when present. |

The handler builds FusionAuth authorization state and returns a temporary redirect to the provider.
:::

```mermaid
sequenceDiagram
  participant Web as Web app
  participant Auth as authentication_service
  participant IdP as OAuth provider
  participant Cache as Redis/session cache

  Web->>Auth: GET /login/sso?idp_name=...&original_url=...
  Auth-->>Web: Redirect to provider authorization URL
  Web->>IdP: Provider login
  IdP-->>Auth: GET /oauth/redirect?code=...&state=...
  Auth->>Auth: Complete authorization code grant
  alt mobile state
    Auth->>Cache: Store refresh token by generated session code
    Auth-->>Web: Redirect original_url?token=session_code
    Web->>Auth: GET /session/login/{session_code}
    Auth-->>Web: UserTokensResponse + cookies
  else web state
    Auth-->>Web: Set cookies + HTML redirect to original_url
  end
```

### Native mobile SSO

`startSsoLogin()` uses the Tauri `plugin:auth|authenticate` command on iOS with:

```ts
{
  authUrl,
  callbackScheme: "macro",
  ephemeralSession: true
}
```

The plugin returns a `token` session code. The client calls `authServiceClient.sessionLogin({ session_code })`, stores returned tokens in `macroAccessToken`, clears any pending token refresh promise, and invalidates auth queries after success.

## Token refresh and API tokens

:::endpoint POST /jwt/refresh Refresh access and refresh tokens
Tokens can be supplied by cookies or by headers:

```http
Authorization: Bearer <access_token>
x-macro-refresh-token: <refresh_token>
```

Behavior:

| Condition | Result |
| --- | --- |
| Access token is still valid | Returns original access and refresh tokens. |
| Access token is expired | Calls FusionAuth refresh, sets new cookies, returns new tokens. |
| Token cannot be decoded | `401 unauthorized`. |
| Refresh token is invalid | `400 invalid refresh token`. |

The frontend coalesces concurrent refreshes: `fetchWithToken()` keeps a single `tokenPromise`, and `authServiceClient.getAccessToken()` keeps a single `ongoingRefresh`.
:::

:::endpoint GET /jwt/macro_api_token Generate Macro API token
Uses the authenticated user context and optional `email` query parameter to issue a `macro_api_token` for the selected profile:

```json
{
  "macro_api_token": "jwt"
}
```

`service-auth/fetch.ts` uses this token as the bearer credential for service calls when bearer-token auth is enabled.
:::

## Logout

`authServiceClient.logout()` clears persisted access-token state and posts to `/logout`. The server clears access and refresh cookies by setting expired cookies, then attempts FusionAuth logout for the current tenant.

`useLogout()` also clears the app's `login=false` cookie, resets cached user info to an unauthenticated shape, tracks `sign_out`, resets analytics, and then:

| Platform | Redirect behavior |
| --- | --- |
| Native mobile | Fetches `SERVER_HOSTS['auth-logout']` with `no-cors`, then navigates to `/login`. |
| Web | Sets `window.location.href` to `SERVER_HOSTS['auth-logout']`. |

## Account links and OAuth callbacks

Account linking requires an authenticated caller.

| Endpoint | Method | Client method | Purpose |
| --- | --- | --- | --- |
| `/link` | `POST` | none | Creates an in-progress link row and returns `{ link_id }`. Limited to 5 in-progress links per FusionAuth user. |
| `/link/github` | `POST` | `initGithubLink(originalUrl?)` | Creates a GitHub in-progress link and returns `{ authorization_url, link_id }`. |
| `/link/github` | `DELETE` | `deleteGithubLink()` | Deletes the user's GitHub link; it does not uninstall the GitHub app from team repositories. |
| `/link/gmail` | `POST` | `initGmailLink(originalUrl?)` | Creates a Gmail in-progress link and returns Google OAuth URL plus `link_id`. |
| `/user/link_exists` | `GET` | `checkLinkExists({ idp_name?, idp_id? })` | Returns `{ link_exists: boolean }`. One of `idp_name` or `idp_id` is expected. |
| `/oauth2/{provider}/callback` | `GET` | provider redirect | Completes custom Google/GitHub login or linking. |

Gmail linking requests Google scopes for OpenID profile/email, Gmail modify, contacts read-only, other contacts read-only, and Gmail settings basic. After Google consent, the callback redirects back to `original_url` with `link_id` appended so the frontend can finish inbox provisioning.

GitHub linking either redirects to `original_url` or returns a small HTML page that posts `{ type: "github-linked", success: true }` to `window.opener` and closes the popup.

<Warning>
The OpenAPI spec includes account merge paths (`/merge`, `/merge/verify/{code}`), but the auth service router shown in `api_router` does not mount `merge::router()`. Treat those paths as not available at runtime unless the router is updated.
</Warning>

## User endpoints

| Endpoint | Method | Client method | Response |
| --- | --- | --- | --- |
| `/user/me` | `GET` | `getUserInfo()` | `{ user_id, organization_id?, permissions }`, mapped to `{ authenticated, permissions, userId, organizationId }`. |
| `/user/me` | `DELETE` | `deleteUser()` | `{ success: boolean }`; client clears persisted token state first. |
| `/user/legacy_user_permissions` | `GET` | `getLegacyUserPermissions()` | Legacy UI aggregate: permissions, email, name, license status, tutorial flag, group, Chrome extension state, trial state, AI consent, referral code. |
| `/user/name` | `GET` | `getUserName()` | `{ id, first_name?, last_name? }`. |
| `/user/name` | `PUT` | `putUserName({ first_name?, last_name? })` | Empty response. |
| `/user/get_names` | `POST` | `getUserNames()` | `{ names: UserName[] }`. |
| `/user/get_names_with_email` | `POST` | `getUserNamesWithEmail()` | `{ names: UserName[] }`, with email-contact fallback. |
| `/user/profile_picture` | `PUT` | `putProfilePicture({ url })` | Empty response. |
| `/user/profile_pictures` | `POST` | `postProfilePictures({ user_id_list })` | `{ pictures: UserProfilePicture[] }`. |
| `/user/organization` | `GET` | `getOrganization()` | `{ organizationId, organizationName }`, or `204`; frontend maps errors/no-content to undefined organization fields. |
| `/user/quota` | `GET` | `userQuota()` | `{ documents, ai_chat_messages, max_documents, max_ai_chat_messages }`, or `204` for premium users with no quota. |
| `/user/tutorial` | `PATCH` | `patchUserTutorial({ tutorialComplete })` | Empty response. |
| `/user/onboarding` | `PATCH` | `completeOnboarding({ firstName, lastName, industry, title })` | Empty response. |
| `/user/group` | `PATCH` | `setGroup({ group })` | Empty response. |
| `/user/ai_consent` | `PATCH` | `patchAiConsent({ aiDataConsent })` | Empty response. |

## Permissions

| Endpoint | Auth | Response |
| --- | --- | --- |
| `GET /permissions` | Not wrapped in JWT middleware in the router | `Permission[]` with `{ id, description }`. |
| `GET /permissions/me` | JWT required | `string[]` permission IDs for the current user. |
| `GET /user/me` | JWT required | Includes current user permissions alongside user and organization IDs. |

## Teams, invites, and roles

All `/team` routes are protected by JWT middleware. Team access checks are enforced with typed extractors for member, admin, and owner roles.

| Role | Used for |
| --- | --- |
| `member` | Read team details. |
| `admin` | Patch team metadata/roles, list team invites, toggle CRM. |
| `owner` | Invite users, delete invites, remove users, delete team. |

### Team models

```ts
type TeamRole = "member" | "admin" | "owner";

interface Team {
  id: string;
  name: string;
  owner_id: string;
  slug: string;
}

interface TeamWithMembers {
  team: Team;
  members: {
    team_id: string;
    user_id: string;
    role: TeamRole;
  }[];
}
```

### Team endpoints

| Endpoint | Method | Client method | Access |
| --- | --- | --- | --- |
| `/team/user` | `GET` | `getUserTeams()` | Authenticated user. |
| `/team/user/invites` | `GET` | `getUserInvites()` | Authenticated user. |
| `/team` | `GET` | `getTeam()` | Team member. |
| `/team` | `POST` | `createTeam({ name })` | Authenticated user. |
| `/team` | `PATCH` | `patchTeam({ name?, slug?, user_role_updates? })` | Admin or owner. |
| `/team` | `DELETE` | `deleteTeam()` | Owner. Cancels team subscription and is irreversible. |
| `/team/invites` | `GET` | `getTeamInvites()` | Admin or owner. |
| `/team/invite` | `POST` | `inviteToTeam({ invites })` | Owner. Returns `201` when invites are created, `304` if nothing new was sent. |
| `/team/invite/{team_invite_id}` | `DELETE` | `deleteTeamInvite(id)` | Owner. |
| `/team/join/{team_invite_id}` | `GET` | `joinTeam(id)` | Invited authenticated user. |
| `/team/join/{team_invite_id}` | `DELETE` | `rejectInvitation(id)` | Invited authenticated user. |
| `/team/remove/{remove_user_id}` | `DELETE` | `removeUserFromTeam(userId)` | Owner. |
| `/team/crm` | `PATCH` | not wrapped in `authServiceClient` | Admin or owner. |

`InviteToTeamRequest` accepts:

```json
{
  "invites": [{ "email": "person@example.com" }]
}
```

Emails are parsed and lowercased. Invalid emails, empty invite lists, and too many emails return `400`.

### Team CRM toggle

:::endpoint PATCH /team/crm Enable or disable team CRM
Request:

```json
{
  "enabled": true
}
```

Response:

```json
{
  "enabled": true,
  "changed": true,
  "backfill_enqueued": 4,
  "backfill_failed": 0
}
```

Enabling CRM enqueues a best-effort `PopulateCrmForUser` message per team member. Disabling CRM purges the team's CRM data through the CRM table cascade.
:::

## Billing and subscription endpoints

Billing endpoints live under `/user/stripe/*` and require an authenticated user.

| Endpoint | Method | Client method | Notes |
| --- | --- | --- | --- |
| `/user/stripe/checkout` | `POST` | `createCheckoutSession()` | Legacy checkout. Accepts optional `tier`; defaults to `haiku`. |
| `/user/stripe/checkoutv2` | `POST` | `createCheckoutSessionV2()` | Current checkout. Backend uses configured price ID and can include team metadata when the caller owns a team. |
| `/user/stripe/portal` | `POST` | `createPortalSession({ returnUrl })` | Returns Stripe billing portal URL. |
| `/user/stripe/subscription` | `PATCH` | `patchSubscriptionTier({ newTier })` | Swaps both RBAC subscription role and Stripe subscription line item. |

### Checkout request shape

```json
{
  "successUrl": "https://macro.com/app/?subscriptionSuccess=true",
  "cancelUrl": "https://macro.com/app?subscriptionCancel=true",
  "discount": "PROMO",
  "metadata": {
    "gaClientId": "optional",
    "fbp": "optional",
    "fbc": "optional"
  }
}
```

The response is:

```json
{
  "url": "https://checkout.stripe.com/..."
}
```

### Subscription tier changes

Supported tiers are:

```ts
type StripeProductTier = "haiku" | "sonnet" | "opus";
```

`PATCH /user/stripe/subscription` rejects:

| Status | Frontend semantic code | Meaning |
| --- | --- | --- |
| `400` | `TIER_UNCHANGED` | The user is already on the requested tier. |
| `403` | `USER_IN_TEAM` | Team members cannot manage their own tier. |
| `404` | `NO_SUBSCRIPTION` | No active subscription or subscription role exists. |
| `409` | `UPDATE_IN_PROGRESS` | Another tier update holds the per-user subscription lock. |
| `500` | `SERVER_ERROR` | Stripe, RBAC, DB, or inconsistent subscription state failure. |

The backend performs the RBAC role swap before the Stripe line-item update and rolls the role swap back if Stripe fails. Stripe requests use a stable idempotency key for the logical tier-swap operation.

## Configuration

The auth service loads configuration from environment variables in `Config::from_env()`.

| Key | Required | Purpose |
| --- | ---: | --- |
| `BASE_URL` | Yes | Public auth service base URL; used for OAuth2 callback URIs. |
| `DATABASE_URL` | Yes | Postgres connection. |
| `REDIS_URI` | Yes | Redis/cache connection for login codes, session codes, and rate limits. |
| `FUSIONAUTH_TENANT_ID` | Yes | FusionAuth tenant. |
| `FUSIONAUTH_API_KEY_SECRET_KEY` | Yes | Secret name, or local raw value in local environment. |
| `FUSIONAUTH_CLIENT_ID` | Yes | FusionAuth application client ID. |
| `FUSIONAUTH_CLIENT_SECRET_KEY` | Yes | Secret name, or local raw value in local environment. |
| `FUSIONAUTH_BASE_URL` | Yes | FusionAuth base URL. |
| `FUSIONAUTH_OAUTH_REDIRECT_URI` | Yes | FusionAuth OAuth redirect URI. |
| `GOOGLE_CLIENT_ID` | Yes | Google OAuth client ID. |
| `GOOGLE_CLIENT_SECRET_KEY` | Yes | Secret name, or local raw value in local environment. |
| `STRIPE_SECRET_KEY` | Yes | Secret name, or local raw value in local environment. |
| `SERVICE_INTERNAL_AUTH_KEY` | Yes | Internal service auth key. |
| `DOCUMENT_STORAGE_SERVICE_URL` | Yes | Document storage service URL. |
| `NOTIFICATION_QUEUE` | Yes | Notification SQS queue. |
| `SEARCH_EVENT_QUEUE` | Yes | Search-event SQS queue. |
| `LINK_MANAGER_QUEUE` | Yes | Email link manager queue. |
| `EMAIL_BACKFILL_QUEUE` | Yes | Team join / CRM backfill queue. |
| `GITHUB_CLIENT_ID` | Yes | GitHub OAuth client ID. |
| `GITHUB_CLIENT_SECRET` | Yes | GitHub OAuth secret. |
| `GITHUB_IDP_ID` | Yes | GitHub identity provider ID. |
| `PORT` | No | Defaults to `8080`. |
| `GA_MEASUREMENT_ID`, `GA_API_SECRET` | No | Google Analytics conversion tracking. |
| `META_PIXEL_ID`, `META_ACCESS_TOKEN`, `META_TEST_EVENT_CODE` | No | Meta conversion tracking. |
| `POSTHOG_API_KEY`, `POSTHOG_HOST` | No | PostHog analytics. |

Cookie names and attributes depend on environment:

| Environment | Cookie prefix | Domain | SameSite |
| --- | --- | --- | --- |
| `Production` | none | `macro.com` | `Strict` |
| `Develop` | `dev-` | `macro.com` | `None` |
| `Local` | `local-` | none | `None` |

Access and refresh cookies are `Secure`, `HttpOnly`, path `/`, and expire in 365 days.

## Client integration notes

- `authApiFetch()` always sends `credentials: "include"`.
- `fetchWithToken()` retries the original request after a `401` by calling `POST /jwt/refresh`.
- `service-auth/fetch.ts` obtains a Macro API token via `GET /jwt/macro_api_token` and sends it as `Authorization: Bearer ...`.
- `authServiceClient.sessionLogin()` and `passwordlessCallback()` persist `{ accessToken, refreshToken, expiresAt }` under `macroAccessToken`.
- User info queries use `getLegacyUserPermissions()` for the main UI cache and mark data stale after 15 seconds.
- Team mutations invalidate the relevant Solid Query team caches after create, patch, delete, invite, join, or reject operations.

## Related pages

<CardGroup>
  <Card title="Service clients" href="/service-clients">
    Frontend client wrappers, `safeFetch`, generated schemas, and mock client registration.
  </Card>
  <Card title="Teams and billing operations" href="/teams-billing">
    Team ownership, Stripe subscriptions, CRM toggles, and operational recovery paths.
  </Card>
</CardGroup>
