# OAuth

Let people sign in to your app with their haunt.gg account.

haunt.gg is an OAuth 2.1 and OpenID Connect provider. Register an app in your
dashboard and you can put a **Log in with haunt.gg** button in your own project:
the user approves what your app may read, and you get an access token and an
OpenID Connect `id_token` — plus a refresh token if you asked for one.

There is nothing bespoke to implement. Point any OIDC client library at the
issuer `https://haunt.gg` and it configures itself from the discovery document;
authorization code + PKCE, refresh, userinfo, revocation and introspection all
behave exactly as the specs say.

## Register your app

Apps are created in the haunt.gg dashboard under **Developer**
(`https://haunt.gg/dashboard/developer`). Dynamic client registration is not
available, and there is no public-client mode: every app is confidential and
gets a `client_id` plus a `client_secret`.

> [!WARNING]
> The client secret is shown **once**, right after you create the app or reset
> it. It is stored hashed, so nobody — not even haunt.gg staff — can show it to
> you again. Resetting the secret takes effect immediately, with no grace
> period: everything still using the old secret starts failing at the token
> endpoint the moment you confirm.

You also pick the app's redirect URLs — up to 10, each at most 250 characters.
They are matched **exactly**, path included: no wildcards, no `#` fragment.
Plain `http` is allowed only on `127.0.0.1` and `localhost`, everything else
must be `https`. A native app may instead register a private-use scheme, which
has to be reverse-domain style — it needs a dot, e.g. `gg.haunt.myapp:/callback`.

If a native or CLI app listens on a random port, register `http://127.0.0.1/…`
rather than `http://localhost/…`: the port is ignored only for the loopback
**IP**, never for the hostname. [Details](/api/oauth/authorize).

## The flow

1. **Send the user to haunt.gg**

   Redirect the browser to the authorization endpoint with your `client_id`,
   one of your registered `redirect_uri` values, `response_type=code`, the
   `scope` list, a `state` value you generate, and a PKCE challenge. PKCE with
   `S256` is **mandatory** for every app.

2. **The user signs in and approves**

   haunt.gg handles the login and shows its own consent screen listing exactly
   the permissions your app requested. If the same user already approved the
   same set of permissions, the screen is skipped.

3. **haunt.gg calls your redirect URL**

   The browser comes back to your `redirect_uri` with `code`, your `state`, and
   an `iss` parameter you should verify equals `https://haunt.gg`.

4. **Exchange the code for tokens**

   From your **server**, POST the code together with your `client_secret` and
   the PKCE verifier to the token endpoint. You get an access token, an
   `id_token`, and — if you asked for `offline_access` — a refresh token.

5. **Read the user**

   Call the userinfo endpoint with the access token, or just read the claims
   out of the `id_token`.

> [!WARNING]
> The token exchange must happen server-side. `/api/auth/oauth2/token` sends no
> CORS headers and answers no preflight, and every app needs its client secret
> to authenticate — a browser-only exchange cannot work.

## Permissions

There are five. An app can never ask for more than it registered — requesting
anything else fails the whole request with `invalid_scope`.

| Scope | On the consent screen | What it gives you |
| --- | --- | --- |
| `openid` | Sign in | The `id_token`, and permission to call userinfo. Without it there is no OpenID Connect flow, so always ask for it. |
| `identify` | Basic profile | Who the user is — `sub`, `uid`, `name`, `preferred_username`, `picture`, `profile`. [Every claim in detail](/api/oauth/userinfo). |
| `email` | Email address | `email` and `email_verified`. |
| `connections` | Connected accounts | The Discord and Last.fm accounts the user linked to haunt.gg. |
| `offline_access` | Refresh session | A refresh token. Leave it out and the token response contains no `refresh_token` at all — once the access token expires an hour later, the user has to walk through the flow again. |

Most integrations want `openid identify`, plus `offline_access` if the app does
anything on the user's behalf while they are away.

> [!NOTE]
> **Email address** and **Connected accounts** are locked. Your app can only
> register them once haunt.gg staff unlocked them for your account — open a
> support ticket at `https://haunt.gg/dashboard/support/new` and tell us what
> your app does. The other three are available to everyone.
> 
> The OIDC standard `profile` scope does **not** exist here — ask for
> `identify`.

## Endpoints

  - **[Discovery](/api/oauth/discovery)** — `GET /.well-known/openid-configuration` — point your OIDC client at
`https://haunt.gg` and let it configure itself.
  - **[Authorize](/api/oauth/authorize)** — `GET /api/auth/oauth2/authorize` — where you send the user to sign in and
approve your app.
  - **[Token](/api/oauth/token)** — `POST /api/auth/oauth2/token` — exchange the code for tokens, and refresh
them later.
  - **[User info](/api/oauth/userinfo)** — `GET /api/auth/oauth2/userinfo` — the claims the user approved.
  - **[Revoke](/api/oauth/revoke)** — `POST /api/auth/oauth2/revoke` — throw a token away early.
  - **[Introspect](/api/oauth/introspect)** — `POST /api/auth/oauth2/introspect` — ask whether a token is still live.

## Token lifetimes

| Token | Lives for | Notes |
| --- | --- | --- |
| Authorization code | 10 minutes | Single use. The login and consent step has to finish inside the same window. |
| Access token | 1 hour | Opaque by default — treat it as a random string, not as a JWT. |
| Refresh token | 30 days | Sliding: every refresh issues a new one and resets the 30 days. |
| `id_token` | 10 hours | Signed with EdDSA. Keys are at `https://haunt.gg/api/auth/jwks`. |

## What is not supported

> [!NOTE]
> - **Dynamic client registration** — apps are created in the dashboard, and
>   `registration_endpoint` is absent from the discovery document.
> - **Public clients** — `token_endpoint_auth_methods_supported` never contains
>   `none`. Every app authenticates with its secret.
> - **The `client_credentials` grant** — it appears in `grant_types_supported`,
>   but haunt.gg apps are registered for `authorization_code` and
>   `refresh_token` only, so it always answers `unauthorized_client`.
> - **RP-initiated logout** — `end_session_endpoint` is advertised but not
>   enabled for haunt.gg apps. Sign the user out in your own app instead.
> - **`plain` PKCE** — only `S256` is accepted.

## When the user takes access back

A user can disconnect your app at any time under **Account → Settings →
Connections**. That deletes their approval **and** every access and refresh
token your app holds for them, so your next refresh answers `invalid_grant`.
Send them through the authorization flow again to reconnect.

haunt.gg staff can also suspend an app. A suspended app is refused at the
authorization endpoint with `client_disabled`, every token grant answers
`invalid_client`, and all of its tokens are deleted — but user approvals are
kept, so restoring the app does not re-prompt everyone.

## Rate limits

100 requests per minute per IP address, counted separately for each endpoint —
so a burst of userinfo calls never eats into your token budget. Going over
answers `429` with an `X-Retry-After` header (seconds to wait) and the body
`{"message":"Too many requests. Please try again later."}`. The discovery
document is cached and not rate limited.
