> ## Documentation Index
> Fetch the complete documentation index at: https://docs.member.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Login with Hub (OAuth / OIDC)

> Turn any member.dev hub into an OpenID Connect Identity Provider so your external app can do one-click SSO with hub members.

Every member.dev hub is a standards-compliant **OpenID Connect (OIDC) Identity Provider**. Your external application registers as an OAuth client, and hub members can sign in to your tool without ever leaving the hub experience — one click, their hub session carries them in.

This guide walks through:

1. Discovering the hub's OIDC configuration
2. Registering an OAuth client (hub owner)
3. Running the Authorization Code + PKCE flow (your app)
4. Verifying the id\_token and calling UserInfo
5. Revoking tokens

***

## 1. OIDC discovery

Every hub exposes a discovery document at the well-known URL. Fetch it once at startup and cache it:

```bash theme={null}
curl -s https://api.member.dev/.well-known/openid-configuration | jq
```

Key fields:

| Field                              | Value                                          |
| ---------------------------------- | ---------------------------------------------- |
| `issuer`                           | `https://api.member.dev`                       |
| `authorization_endpoint`           | `https://api.member.dev/oauth/authorize`       |
| `token_endpoint`                   | `https://api.member.dev/oauth/token`           |
| `userinfo_endpoint`                | `https://api.member.dev/oauth/userinfo`        |
| `jwks_uri`                         | `https://api.member.dev/.well-known/jwks.json` |
| `revocation_endpoint`              | `https://api.member.dev/oauth/revoke`          |
| `code_challenge_methods_supported` | `["S256"]`                                     |
| `scopes_supported`                 | `["openid", "profile", "email"]`               |
| `prompt_values_supported`          | `["none"]`                                     |

Fetch the JWKS from `jwks_uri` to verify id\_token signatures:

```bash theme={null}
curl -s https://api.member.dev/.well-known/jwks.json | jq
```

The JWKS is a standard RSA public-key set. Use any OIDC/JWT library to verify RS256 signatures.

***

## 2. Register an OAuth client (hub owner)

Hub owners register OAuth clients via the platform API. You need a platform JWT (creator login) or a team API key with write access.

### Register a PKCE public client (recommended for SPAs and native apps)

```bash theme={null}
curl -s -X POST "https://api.member.dev/api/v1/teams/$TEAM_ID/oauth-clients" \
  -H "Authorization: Bearer $PLATFORM_TOKEN" \
  -H "Content-Type: application/vnd.api+json" \
  -d '{
    "data": {
      "type": "oauth_clients",
      "attributes": {
        "name": "My External Tool",
        "is_public": true,
        "brand_label": "My Tool",
        "logo_url": "https://my-tool.example.com/logo.png",
        "redirect_uris": ["https://my-tool.example.com/callback"]
      }
    }
  }' | jq
```

Public (`is_public: true`) clients use PKCE instead of a client secret. No secret is returned or stored.

### Register a confidential client (server-to-server / backend apps)

```bash theme={null}
curl -s -X POST "https://api.member.dev/api/v1/teams/$TEAM_ID/oauth-clients" \
  -H "Authorization: Bearer $PLATFORM_TOKEN" \
  -H "Content-Type: application/vnd.api+json" \
  -d '{
    "data": {
      "type": "oauth_clients",
      "attributes": {
        "name": "My Backend Service",
        "is_public": false,
        "redirect_uris": ["https://my-service.example.com/oauth/callback"]
      }
    }
  }' | jq '.data.attributes | {client_id, client_secret}'
```

**Store `client_secret` now — this is the ONLY time it is returned.** Confidential clients must present it on every token request via HTTP Basic auth (`client_secret_basic`).

### Create request attributes

| Attribute       | Type             | Required | Notes                                                    |
| --------------- | ---------------- | -------- | -------------------------------------------------------- |
| `name`          | string           | Yes      | Human-readable name shown on the consent screen          |
| `is_public`     | boolean          | No       | `true` = PKCE public client (no secret); default `false` |
| `brand_label`   | string           | No       | Short label shown on the consent screen                  |
| `logo_url`      | string           | No       | Logo shown on the consent screen                         |
| `redirect_uris` | array of strings | No       | Can be set at create time or added later                 |

### Add or remove redirect URIs later

```bash theme={null}
# Add a URI
curl -s -X POST "https://api.member.dev/api/v1/teams/$TEAM_ID/oauth-clients/$CLIENT_ID/redirect-uris" \
  -H "Authorization: Bearer $PLATFORM_TOKEN" \
  -H "Content-Type: application/vnd.api+json" \
  -d '{"data": {"type": "oauth_redirect_uris", "attributes": {"redirect_uri": "https://my-tool.example.com/alt-callback"}}}'

# Delete a URI (use the uri_id from the list response)
curl -s -X DELETE "https://api.member.dev/api/v1/teams/$TEAM_ID/oauth-clients/$CLIENT_ID/redirect-uris/$URI_ID" \
  -H "Authorization: Bearer $PLATFORM_TOKEN"
```

Redirect URIs must use HTTPS, contain no fragment (`#`), and no userinfo component. The `redirect_uri` in the authorization request must **exactly** match one of the registered URIs — any mismatch returns a `400` error (never a redirect to an unregistered URI, per RFC 6749 §10.6).

***

## 3. The Authorization Code + PKCE flow

```
Your app                Hub browser session               mio-backend
   │                           │                               │
   │──── 1. Build PKCE ────────┤                               │
   │      code_verifier (rand) │                               │
   │      code_challenge=      │                               │
   │        BASE64URL(SHA256   │                               │
   │        (code_verifier))   │                               │
   │                           │                               │
   │──── 2. GET /oauth/authorize ──────────────────────────────▶│
   │         ?response_type=code                               │
   │         &client_id=...                                    │
   │         &redirect_uri=...                                 │
   │         &scope=openid profile email                       │
   │         &state=OPAQUE                                     │
   │         &nonce=OPAQUE                                     │
   │         &code_challenge=...                               │
   │         &code_challenge_method=S256                       │
   │                           │                               │
   │                           │←── consent screen (if needed) │
   │                           │──── POST /oauth/consent ─────▶│
   │                           │                               │
   │←── 302 redirect_uri?code=CODE&state=STATE&iss=... ────────│
   │                           │                               │
   │──── 3. POST /oauth/token ─────────────────────────────────▶│
   │         grant_type=authorization_code                     │
   │         code=CODE                                         │
   │         redirect_uri=...                                  │
   │         client_id=...                                     │
   │         code_verifier=...   (PKCE)                        │
   │         [Basic auth for confidential clients]             │
   │                           │                               │
   │←── {id_token, access_token, refresh_token} ───────────────│
```

### Step 1: build PKCE parameters

Generate a cryptographically random `code_verifier` (43–128 URL-safe chars), then compute:

```
code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
code_challenge_method = S256
```

Most OAuth libraries (e.g. `pkce-challenge`, `oauth4webapi`) do this for you.

### Step 2: redirect to the authorization endpoint

```bash theme={null}
# Redirect the user's browser to:
https://api.member.dev/oauth/authorize
  ?response_type=code
  &client_id=CLIENT_ID
  &redirect_uri=https%3A%2F%2Fmy-tool.example.com%2Fcallback
  &scope=openid%20profile%20email
  &state=RANDOM_OPAQUE_STATE
  &nonce=RANDOM_OPAQUE_NONCE
  &code_challenge=CODE_CHALLENGE
  &code_challenge_method=S256
```

**Parameters:**

| Parameter               | Required    | Notes                                                                                                                                                                              |
| ----------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `response_type`         | Yes         | Must be `code`                                                                                                                                                                     |
| `client_id`             | Yes         | The `client_id` from registration                                                                                                                                                  |
| `redirect_uri`          | Yes         | Must exactly match a registered URI                                                                                                                                                |
| `scope`                 | Yes         | Space-separated; include `openid` plus any of `profile`, `email`                                                                                                                   |
| `state`                 | Recommended | Opaque value; verified by your app on callback to prevent CSRF                                                                                                                     |
| `nonce`                 | Recommended | Embedded in the id\_token; prevents replay attacks                                                                                                                                 |
| `code_challenge`        | Yes         | PKCE S256 challenge                                                                                                                                                                |
| `code_challenge_method` | Yes         | Must be `S256`                                                                                                                                                                     |
| `prompt`                | No          | `none` = return `login_required` immediately if there is no active hub session (no UI shown); omit for default. Advertised in `prompt_values_supported` in the discovery document. |

**What happens next:**

* `/oauth/authorize` reads the member's **hub session cookie** (as well as a Bearer token if present). A browser top-level redirect therefore works for already-logged-in members without any extra step — this is the one-click SSO path.
* If the member has an active hub session → the server checks consent. First-party clients (set by platform admins) auto-skip consent. Third-party clients show a consent screen.
* If consent is required → the browser receives a `200` with a `consent_request_id`. Your consent UI submits it to `POST /oauth/consent`.
* If **no session is present** → returns `login_required` (this is the only error returned in the no-session case, including when `prompt=none` — `interaction_required` is not used). The hub's front end should handle this by starting a login flow (magic link, external login, etc.) and then retrying the authorization. Automatic magic-link bounce is a planned fast-follow.
* On approval → `302` to your `redirect_uri` with `code`, `state`, and `iss`.

### Step 3: exchange the code for tokens

```bash theme={null}
curl -s -X POST "https://api.member.dev/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=$CODE" \
  -d "redirect_uri=https://my-tool.example.com/callback" \
  -d "client_id=$CLIENT_ID" \
  -d "code_verifier=$CODE_VERIFIER"
```

For **confidential clients**, add HTTP Basic auth:

```bash theme={null}
curl -s -X POST "https://api.member.dev/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d "grant_type=authorization_code" \
  -d "code=$CODE" \
  -d "redirect_uri=https://my-tool.example.com/callback" \
  -d "client_id=$CLIENT_ID" \
  -d "code_verifier=$CODE_VERIFIER"
```

**Response:**

```json theme={null}
{
  "access_token": "eyJ...",
  "token_type": "bearer",
  "expires_in": 3600,
  "refresh_token": "eyJ...",
  "id_token": "eyJ...",
  "scope": "openid profile email"
}
```

### Refresh the access token

```bash theme={null}
curl -s -X POST "https://api.member.dev/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=$REFRESH_TOKEN" \
  -d "client_id=$CLIENT_ID"
  # + Basic auth for confidential clients
```

***

## 4. Verify the id\_token

The `id_token` is a signed RS256 JWT. Verify it with any standard OIDC library using the JWKS from `/.well-known/jwks.json`.

**Verification checklist (per OIDC Core §3.1.3.7):**

1. Signature valid against the published JWKS
2. `iss` = `https://api.member.dev`
3. `aud` contains your `client_id`
4. `exp` has not passed
5. `nonce` matches the value you sent in the authorization request

**Claims:**

| Claim                 | Always present       | Notes                                                                                  |
| --------------------- | -------------------- | -------------------------------------------------------------------------------------- |
| `sub`                 | Yes                  | The member's contact ID (UUID). Use this as the stable user identifier in your system. |
| `hub_id`              | Yes                  | The hub the member belongs to                                                          |
| `iss`                 | Yes                  | `https://api.member.dev`                                                               |
| `aud`                 | Yes                  | Your `client_id`                                                                       |
| `exp` / `iat` / `nbf` | Yes                  | Standard JWT lifetime claims                                                           |
| `nonce`               | Yes                  | Echoes back the nonce from the authorization request                                   |
| `auth_time`           | Yes                  | Unix timestamp of authentication                                                       |
| `email`               | With `email` scope   | Member's email address                                                                 |
| `email_verified`      | With `email` scope   | Always `true` for hub members                                                          |
| `name`                | With `profile` scope | Member's display name (when available)                                                 |
| `picture`             | With `profile` scope | Avatar URL via imgproxy (when set)                                                     |

**Important:** the `sub` claim is the member's **contact ID** within this hub — not a synthetic platform-level user ID. Your app should store `sub` as the foreign key linking to the member.

***

## 5. Call the UserInfo endpoint

For fresher claims after the initial login, call UserInfo with the access token:

```bash theme={null}
curl -s "https://api.member.dev/oauth/userinfo" \
  -H "Authorization: Bearer $ACCESS_TOKEN" | jq
```

The access token audience must be `mio-oauth`. Standard member JWT tokens (audience `mio-api`) are rejected with `401`. Use only tokens obtained from `POST /oauth/token`.

**Response (openid + profile + email scopes):**

```json theme={null}
{
  "sub": "contact-uuid-here",
  "hub_id": "hub-uuid-here",
  "email": "alice@example.com",
  "email_verified": true,
  "name": "Alice Example",
  "picture": "https://cdn.member.dev/avatars/..."
}
```

***

## 6. Revoke a token

Revoke an access or refresh token when the member logs out of your app (RFC 7009):

```bash theme={null}
curl -s -X POST "https://api.member.dev/oauth/revoke" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "token=$TOKEN" \
  -d "token_type_hint=access_token"
  # + Basic auth for confidential clients
```

The server always returns `200` regardless of whether the token was found — no information about token existence is leaked. Revoking a refresh token also invalidates all sibling tokens issued in the same refresh family.

***

## 7. Worked example: one-click SSO

**Scenario:** Alice is a member of "Lab Creator" hub (run by Bob). Bob builds an external experiment-tracking tool and wants Alice to log in with her hub account.

```
1. Bob registers an OAuth client:
   POST /api/v1/teams/BOB_TEAM_ID/oauth-clients
   → client_id: "abc123", is_public: true
   → registers redirect_uri: "https://labtracker.example.com/callback"

2. Bob adds "Login with Lab Creator" button to labtracker.example.com.
   Button href:
     https://api.member.dev/oauth/authorize
       ?response_type=code&client_id=abc123
       &redirect_uri=https%3A%2F%2Flabtracker.example.com%2Fcallback
       &scope=openid%20profile%20email
       &state=RAND_STATE&nonce=RAND_NONCE
       &code_challenge=CODE_CHALLENGE&code_challenge_method=S256

3. Alice (already signed into Lab Creator hub in her browser) clicks the button.
   → Browser performs a top-level redirect to /oauth/authorize.
   → mio-backend reads Alice's hub session cookie — she is already logged in (one click, no password prompt).
   → Validates the request, auto-skips consent (first-party or already approved).
   → Redirects to: https://labtracker.example.com/callback?code=CODE&state=RAND_STATE&iss=...

4. labtracker.example.com POSTs to /oauth/token:
   → Receives id_token (sub="ALICE_CONTACT_ID", hub_id="LAB_CREATOR_HUB_ID") + access_token

5. labtracker.example.com verifies id_token signature against JWKS.
   → Finds or creates a local user record keyed on sub="ALICE_CONTACT_ID".
   → Alice is logged in. One click, no password.
```

***

## Manage clients

```bash theme={null}
# List all clients for your team
curl -s "https://api.member.dev/api/v1/teams/$TEAM_ID/oauth-clients" \
  -H "Authorization: Bearer $PLATFORM_TOKEN" | jq '.data[].attributes | {client_id, name, is_public}'

# Retrieve a specific client
curl -s "https://api.member.dev/api/v1/teams/$TEAM_ID/oauth-clients/$CLIENT_ID" \
  -H "Authorization: Bearer $PLATFORM_TOKEN" | jq

# Delete a client (also deletes all consent grants)
curl -s -X DELETE "https://api.member.dev/api/v1/teams/$TEAM_ID/oauth-clients/$CLIENT_ID" \
  -H "Authorization: Bearer $PLATFORM_TOKEN"
# → 204 No Content
```

***

## Security notes

* **PKCE is mandatory** — missing or non-S256 `code_challenge_method` returns an authorization error.
* **Exact redirect\_uri match** — any mismatch returns a `400` JSON error body (never a redirect), preventing open redirector attacks.
* **hub\_id is server-derived** — the hub is resolved from the OAuth client record, never from user-supplied parameters.
* **Token audience** — oauth access tokens carry `aud: mio-oauth`. They are rejected on all non-oauth endpoints; standard member API tokens are rejected at the userinfo/revoke endpoints.
* **Revocation ownership** — a client can only revoke tokens it issued. Revocation verifies the token's RS256 signature before any state changes, so a forged token cannot blacklist a victim's session.
