# Authentication
URL: https://openship.io/docs/api/auth.md

Browser sessions, OAuth discovery, and authentication handoffs.

import { TypeTable } from 'fumadocs-ui/components/type-table';

The Auth API is how Openship signs users in and keeps their session. Almost everything here is served by
[Better Auth](https://better-auth.com) through a single catch-all — email/password sign-in and sign-up, OAuth,
the organization/member/invitation endpoints, and the MCP OAuth authorization server. A small set of
**desktop-only** endpoints sit in front of it to bootstrap a zero-auth local session and hand off cloud sign-in
to the downloadable app.

This module is consumed almost entirely by the dashboard (and, in desktop mode, the Electron host) — you rarely
call it directly. For programmatic API access, mint a personal access token instead
([`openship token create`](/docs/cli/access)) and use it as a bearer header. See the
[API overview](/docs/api) and [Auth & sessions](/docs/security/auth) for the full model.

## The Better Auth catch-all

The last route, `GET|POST /api/auth/*`, forwards the raw request to Better Auth's handler. Everything a normal
sign-in flow needs lives here — there is no separate schema in this module because Better Auth owns the request
and response shapes. The endpoints it serves (all under `/api/auth`) include:

- **Credentials** — `POST /sign-in/email`, `POST /sign-up/email`, and `POST /sign-out`. Passwords are
  8–128 characters. Email verification and password reset use codes through
  `POST /email-otp/send-verification-otp`, `POST /email-otp/verify-email`, and
  `POST /email-otp/reset-password`; delivery requires working instance mail or Cloud mail.
- **OAuth** — `POST /sign-in/social` and `GET /callback/:provider` for GitHub and Google (each enabled only
  when its client ID/secret is set).
- **Passkeys** — `GET /passkey/generate-register-options`, `POST /passkey/verify-registration`,
  `GET /passkey/generate-authenticate-options`, `POST /passkey/verify-authentication`,
  `GET /passkey/list-user-passkeys`, and `POST /passkey/delete-passkey`. Use the Better Auth browser
  client to perform the WebAuthn ceremony; raw credentials are never a substitute for it.
- **Two-factor authentication** — `POST /two-factor/enable` returns an authenticator URI and recovery
  codes after checking the current password. `POST /two-factor/verify-totp` confirms enrollment or a
  pending login. `POST /two-factor/verify-backup-code` consumes a recovery code. Disabling protection
  (`POST /two-factor/disable`) and replacing codes (`POST /two-factor/generate-backup-codes`) require
  the current password. OAuth-only accounts can add their first password through
  `POST /account-security/set-password` with a recent authenticated session.
- **Organizations** — `/organization/*`: create, `set-active`, `list`, `invite-member`, `accept-invitation`,
  `update-member-role`, `remove-member`, `leave`, and more. Member-listing reads are additionally shielded so
  restricted/member roles can't read admin-tier data.
- **MCP OAuth 2.1** — `/mcp/authorize`, `/mcp/token`, `/mcp/register` (dynamic client registration),
  `/mcp/get-session`, plus discovery at `/.well-known/oauth-authorization-server` and
  `/.well-known/oauth-protected-resource` (also re-served at the origin root). See the [MCP API](/docs/api/mcp).

<Callout title="Roles and permissions live elsewhere" type="info">
Better Auth's organization plugin only carries the role label (`owner`, `admin`, `member`, or `restricted`).
The actual authorization for the rest of the API is resolved against resource grants — see
[Permissions](/docs/security/permissions).
</Callout>

For an account with 2FA enabled, a primary sign-in returns `{ "twoFactorRedirect": true }` instead of
a usable session. Preserve the signed challenge cookie and submit an authenticator or recovery code;
only successful verification issues the session. Social callbacks redirect to the dashboard's
`/two-factor` page and resume their validated destination after verification. Account security endpoints
are available only where local accounts are hosted, including Openship Cloud's account service and
self-hosted VPS installations. See [Auth & sessions](/docs/security/auth#two-factor-authentication).

## Get the current session

```
GET /api/auth/get-session
```

Returns the active session and user as JSON, or `401` when unauthenticated. In zero-auth desktop mode
(self-hosted onboarding, no password), a miss triggers an inline bootstrap: Openship provisions the local admin
user, mints a session, sets the cookie on the response, and returns it — so the dashboard's first navigation
succeeds instead of looping to `/login`.

```bash
curl "$OPENSHIP_URL/api/auth/get-session" \
  -H "Cookie: openship.session_token=<session>"
```

## Desktop session bootstrap

`GET /api/auth/desktop-login` mints a session for the zero-auth local admin and redirects to the dashboard.
It is called once, right after self-hosted onboarding completes; if the instance isn't in zero-auth mode it
redirects to `/login` instead. No request body.

## Cloud sign-in handoff (desktop)

When the downloadable app signs in "with Cloud", the Electron host runs a PKCE flow and the API brokers it. The
sequence is: register the nonce, open the system browser, poll, then claim.

```
POST /api/auth/desktop-auth-start
```

Guarded by the internal shared-token middleware — only the Electron host may register a nonce. Validated inline
(there is no TypeBox schema module for auth); all three fields are required strings:

<TypeTable
  type={{
    nonce: { type: 'string', description: 'Opaque handle the Electron main process polls on.', required: true },
    state: { type: 'string', description: 'OAuth state value echoed back on the cloud callback.', required: true },
    code_verifier: { type: 'string', description: 'PKCE code verifier; paired with the code exchanged at cloud-callback.', required: true },
  }}
/>

The remaining steps take query parameters, not bodies:

- `GET /api/auth/cloud-callback?code=<code>&state=<state>` — the cloud provider redirects here. With `state`
  it runs the desktop PKCE exchange and resolves the pending nonce; without `state` it runs the simpler browser
  exchange and sets the cookie directly. `code` is required.
- `GET /api/auth/desktop-auth-poll?nonce=<nonce>` — Electron polls until the status is `resolved`, then opens
  the claim URL. `nonce` is required.
- `GET /api/auth/desktop-claim?code=<code>` — exchanges the one-time claim code for a session cookie (set via
  the `Set-Cookie` header for cross-version Electron reliability) and redirects to the dashboard. `code` is
  required.

## Errors

<Callout title="401 — Unauthorized" type="warn">
`GET /api/auth/get-session` returns this when there's no valid session and the instance is not in zero-auth
mode. Sign in through the dashboard, or send a valid session cookie / bearer token.
</Callout>

<Callout title="400 — missing nonce, state, or code_verifier" type="error">
`POST /api/auth/desktop-auth-start` rejects a body that's missing any of the three fields. The poll and claim
routes return `400` similarly when their required query parameter is absent, and claim returns `400 Claim
expired` once the one-time code has been used or has timed out.
</Callout>

<Callout title="429 — too many requests" type="warn">
`POST /api/auth/*` shares a 10/min per-IP bucket. Sign-in/sign-up loops that hammer it will be throttled before
the default API limit fires.
</Callout>

## Operations

{/* api-operations:start */}

| Operation | REST API |
| --- | --- |
| Handle browser sessions and authentication through Better Auth. | `GET /api/auth/*`<br />`Handler authentication` |
| Complete a browser-bound Cloud repository authorization; ordinary GitHub sign-in callbacks remain handled by Better Auth. | `GET /api/auth/callback/github`<br />`Handler authentication` |
| Handle sign-in, account, and session mutations through Better Auth. | `POST /api/auth/*`<br />`Handler authentication` |
| Exchange a cloud auth `code` for a local session cookie. | `GET /api/auth/cloud-callback`<br />`Handler authentication` |
| Electron polls this by `nonce` until the cloud sign-in resolves. | `GET /api/auth/desktop-auth-poll`<br />`Handler authentication` |
| Register a `(nonce, state, PKCE verifier)` tuple before the system browser opens. | `POST /api/auth/desktop-auth-start`<br />`Internal operator` |
| Exchange a one-time claim `code` for a session cookie, then redirect. | `GET /api/auth/desktop-claim`<br />`Handler authentication` |
| Mint a session for the zero-auth local admin and redirect to the dashboard. | `GET /api/auth/desktop-login`<br />`Handler authentication` |
| Return the current session; in zero-auth desktop mode, bootstrap one on first hit. | `GET /api/auth/get-session`<br />`Handler authentication` |
| Read a safe preview of a team invitation. | `GET /api/auth/invitation-preview/:id`<br />`Handler authentication` |
| Read the public keys used to verify MCP OAuth tokens. | `GET /api/auth/mcp/jwks`<br />`Handler authentication` |
| Read the identity associated with an MCP OAuth token. | `GET /api/auth/mcp/userinfo`<br />`Handler authentication` |
| Accept an invitation through the browser session boundary. | `POST /api/auth/organization/accept-invitation`<br />`Handler authentication` |
| Cancel a pending invitation through the browser session boundary. | `POST /api/auth/organization/cancel-invitation`<br />`Handler authentication` |
| Invite a member through the browser session boundary. | `POST /api/auth/organization/invite-member`<br />`Handler authentication` |
| Leave the active organization. | `POST /api/auth/organization/leave`<br />`Handler authentication` |
| Reject a pending invitation through the browser session boundary. | `POST /api/auth/organization/reject-invitation`<br />`Handler authentication` |
| Remove a member through the browser session boundary. | `POST /api/auth/organization/remove-member`<br />`Handler authentication` |
| Update a member role through the browser session boundary. | `POST /api/auth/organization/update-member-role`<br />`Handler authentication` |
| Handle sign-up with the installation's account-creation policy. | `POST /api/auth/sign-up/*`<br />`Handler authentication` |
| Discover the OAuth authorization server. | `GET /.well-known/oauth-authorization-server`<br />`Public` |
| Discover the OAuth authorization server. | `GET /.well-known/oauth-authorization-server/api/mcp`<br />`Public` |
| Discover the OAuth authorization server. | `GET /.well-known/oauth-authorization-server/api/proxy/api/mcp`<br />`Public` |
| Discover OAuth metadata for this protected resource. | `GET /.well-known/oauth-protected-resource`<br />`Public` |
| Discover OAuth metadata for this protected resource. | `GET /.well-known/oauth-protected-resource/api/mcp`<br />`Public` |
| Discover OAuth metadata for this protected resource. | `GET /.well-known/oauth-protected-resource/api/proxy/api/mcp`<br />`Public` |
| Close a completed browser authentication handoff. | `GET /auth/callback/close`<br />`Browser callback` |
| Complete the GitHub App browser handoff using installation state. | `GET /auth/callback/install`<br />`Browser callback` |

{/* api-operations:end */}
