# Authorize

Send the user to haunt.gg to sign in and approve your app.

The start of the authorization code flow. Redirect the user's **browser** here —
this is not an endpoint you call from your server. haunt.gg handles the login,
shows its consent screen, and sends the browser back to your `redirect_uri` with
a one-time code.

**GET** `/api/auth/oauth2/authorize`

> [!WARNING]
> PKCE is mandatory for every haunt.gg app, and `S256` is the only accepted
> method. Generate a random `code_verifier`, send its SHA-256 hash as
> `code_challenge`, and keep the verifier for the token exchange.

## Query Parameters

| Name | Type | In | Required | Description |
| --- | --- | --- | --- | --- |
| `client_id` | string | query | yes | Your app's client ID from the Developer page. |
| `redirect_uri` | string | query | yes | One of your app's registered redirect URLs. Matched exactly, path included. The single exception is the loopback IP `127.0.0.1` (and `::1`), where the port is ignored so a native app can listen on a random one — see the note below. |
| `response_type` | string | query | yes | Must be `code`. Anything else is rejected before the flow starts. |
| `code_challenge` | string | query | yes | Base64url of the SHA-256 hash of your `code_verifier`. Required — PKCE is not optional here. |
| `code_challenge_method` | string | query | yes | Must be `S256`. `plain` is rejected. |
| `scope` | string | query | no | Space-separated permissions, e.g. `openid identify email`. Every entry must be one your app is registered for. Omit it entirely and haunt.gg grants your app's full registered scope set. |
| `state` | string | query | no | Opaque value echoed back to your redirect URL. Use it to tie the callback to the browser session that started the flow — this is your CSRF defence. |
| `nonce` | string | query | no | Echoed into the `id_token` as the `nonce` claim. Verify it after the exchange to bind the token to this request. |
| `prompt` | string | query | no | One of `none`, `login`, `consent`, `create`, `login consent`. `login` and `create` force a fresh sign-in; `consent` always re-shows the approval screen; `none` never shows any UI and instead fails with a redirect error. `select_account` is advertised in discovery but not supported. |

> [!WARNING]
> **Native apps: register `127.0.0.1`, not `localhost`.** The port is only
> ignored for loopback **IP literals** (RFC 8252 §7.3). Register
> `http://localhost:3000/callback` and come back on port 51734 and the request
> is refused with `invalid_redirect`; register `http://127.0.0.1:3000/callback`
> and any port works. Both are accepted at registration time, so this only
> surfaces once you are testing.

> [!NOTE]
> Unknown query parameters are dropped, not passed through. `login_hint`,
> `id_token_hint`, `max_age`, `display`, `ui_locales`, `acr_values` and
> `resource` are all ignored at this endpoint — `resource` is only honoured at
> the token endpoint.

## Example Request

  #### JavaScript

```ts
// 1. PKCE
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(digest));

// Keep `verifier` and `state` in the user's session — you need them later.
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));

const url = new URL("https://haunt.gg/api/auth/oauth2/authorize");
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("redirect_uri", "https://example.com/auth/callback");
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", "openid identify offline_access");
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");

redirect(url.toString());
```
  #### Python

```python
import base64, hashlib, os, urllib.parse

verifier = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
state = base64.urlsafe_b64encode(os.urandom(16)).rstrip(b"=").decode()

params = {
    "client_id": CLIENT_ID,
    "redirect_uri": "https://example.com/auth/callback",
    "response_type": "code",
    "scope": "openid identify offline_access",
    "state": state,
    "code_challenge": challenge,
    "code_challenge_method": "S256",
}
url = "https://haunt.gg/api/auth/oauth2/authorize?" + urllib.parse.urlencode(params)
```
  #### URL

```text
https://haunt.gg/api/auth/oauth2/authorize
  ?client_id=YOUR_CLIENT_ID
  &redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Fcallback
  &response_type=code
  &scope=openid%20identify%20offline_access
  &state=RANDOM_STATE
  &code_challenge=BASE64URL_SHA256_OF_VERIFIER
  &code_challenge_method=S256
```

## Response

This endpoint never returns JSON. It answers with a `302` to your redirect URL —
either with a code, or with an OAuth error.

  #### 302 — Approved. `state` comes back exactly as you sent it, and `iss` is always appended — verify it equals https://haunt.gg before you exchange the code. The code is single-use and expires after 10 minutes.

```text
https://example.com/auth/callback
  ?code=1a2B3c4D5e6F7g8H9i0J1k2L3m4N5o6P
  &state=RANDOM_STATE
  &iss=https://haunt.gg
```
  #### 302 — Refused, but recognised: the redirect URL was valid, so the error goes to your app. `error_description` carries a human-readable reason, `state` and `iss` are appended as usual.

```text
https://example.com/auth/callback
  ?error=access_denied
  &error_description=User+denied+access
  &state=RANDOM_STATE
  &iss=https://haunt.gg
```

## Errors your app receives

| `error` | When |
| --- | --- |
| `access_denied` | The user pressed Cancel on the consent screen. |
| `invalid_scope` | A requested scope is not registered on your app. `error_description` names them. |
| `invalid_request` | PKCE problem — the challenge and method were not both sent, or the method was not `S256`. |
| `login_required` | `prompt=none` but the user is not signed in. |
| `consent_required` | `prompt=none` but the user has not approved these permissions yet. |
| `interaction_required` | `prompt=none` but the flow needs a UI step. |
| `account_selection_required` | `prompt=none` and the account could not be determined. |

## Errors your app never sees

> [!WARNING]
> Some failures happen **before** the redirect URL is validated, and OAuth
> forbids redirecting to an unverified URL. Those send the *user* to
> `https://haunt.gg/login?error=…&error_description=…` and your app simply never
> gets a callback. This is what you are looking at when a test flow appears to
> hang on the haunt.gg login page:
> 
> - `invalid_client` — unknown or missing `client_id`
> - `invalid_redirect` — the `redirect_uri` is not registered, character for character
> - `client_disabled` — the app was suspended by haunt.gg staff
> - `unauthorized_client` — the app is not registered for the authorization code grant
> - `unsupported_response_type` — `response_type` was missing or not `code`

## Consent

haunt.gg shows its own consent screen with your app's name, the owner's handle,
and your website, terms and privacy links if you registered them. It lists
exactly the permissions in your `scope`.

If the same user already approved the same set of permissions, the screen is
skipped and the browser bounces straight back with a code. Adding a scope later
re-prompts. `prompt=consent` always re-prompts.

## Rate limit

100 requests per minute per IP address, counted for this endpoint alone.
