# auth.md

You are an agent. This service exposes an HTTP API secured with OAuth 2.0 / OpenID Connect. Authenticate with the Authorization Code flow with PKCE (S256): discover → pick a method → register → use the credential → handle errors → revoke. Follow the steps in order.

This document lives at https://kellytalkssocial.myteachify.com/auth.md.

## Step 1 — Discover

Fetch this origin's Protected Resource Metadata (RFC 9728) directly at the conventional well-known path below. (A protected endpoint that receives an invalid token, or a token lacking a required scope, also returns a `WWW-Authenticate` header pointing here. An unauthenticated request to `/mcp` is served as anonymous, while `/member/graphql` challenges it with 401 — either way, do not rely on provoking a challenge.)

- Protected Resource Metadata: [/.well-known/oauth-protected-resource](https://kellytalkssocial.myteachify.com/.well-known/oauth-protected-resource)
- Authorization Server Metadata (RFC 8414): [/.well-known/oauth-authorization-server](https://kellytalkssocial.myteachify.com/.well-known/oauth-authorization-server)
- OpenID Connect Discovery: [/.well-known/openid-configuration](https://kellytalkssocial.myteachify.com/.well-known/openid-configuration)

The authorization-server metadata lists every endpoint, grant type, and scope below. Fetch it first and rely on it as the source of truth.

## Step 2 — Pick a method

This service supports the standard OAuth 2.0 Authorization Code flow with PKCE. Public clients use `code_challenge_method=S256` and the `none` token-endpoint auth method; confidential clients use `client_secret_post`.

> The WorkOS `agent_auth` extension (autonomous `identity_assertion` via ID-JAG, `service_auth`, and the anonymous claim ceremony) is **not yet supported** here. Do not attempt an ID-JAG assertion or call an `identity_endpoint`/`register_uri` for it — use the Authorization Code + PKCE flow below.

## Step 3 — Register

**To act for a signed-in member — their enrollments, progress and orders — the site registers you.** An operator creates the client in the site's own admin area (Developers → OAuth applications, which appears once they enable the "MCP connections" beta feature), picks its scopes, and gives you the `client_id`. Anyone with a role at that site can then authorize it, students included. There is no self-serve route: a member token reads one person's data at one site, and the site decides which third parties may hold one.

That form has a **confidential application** checkbox, and it decides which token-endpoint auth method your client uses:

- **Unchecked — a public client.** No secret is issued; use `token_endpoint_auth_method=none` with PKCE. This is the right choice for a browser or mobile client, and it needs no backend of your own.
- **Checked — a confidential client.** A secret is issued and is required at the token and revocation endpoints, so the client needs a server to hold it. `none` will be refused.

The applications list and each application page show which one it is. If you built for a public client and the token endpoint rejects `none`, the application was created with the box checked — uncheck it, or create a second application without it.

### Operator clients

A client that acts for a site **operator** rather than a member registers itself, by one of the two routes below.

The registration endpoint (advertised as `registration_endpoint` in the authorization-server metadata), `https://kellytalkssocial.myteachify.com/api/oauth/register`, issues credentials for pre-registered vendor platforms only: it matches your `client_name` against a known integration and returns that platform's `client_id`. **It is an allowlist match, not open dynamic client registration (RFC 7591)** — an unrecognized `client_name` is refused with `invalid_client_metadata`, and no request body will change that.

To identify a client that is not a pre-registered vendor — a CLI tool, local agent, or hosted web client — publish a Client ID Metadata Document over HTTPS and use its URL as your `client_id` (check `client_id_metadata_document_supported` in the discovery document first). The document resolves only where this server permits its host — an allowlisted host, or any host when open registration is enabled; a host that is neither is rejected, so contact support or use a pre-registered vendor platform. Its `redirect_uris` must sit under the document host or be a loopback address, and while unreviewed it may request read and identity scopes only.

> **The walkthrough below is the member case.** It requests `account:read`, which an operator client cannot hold — that scope is reserved to the member audience, so requesting it from a client registered at the endpoint above fails with `invalid_scope`. An operator client follows the same steps with the scopes from the Scopes table instead, and reaches the admin API rather than the member's own data.

## Step 4 — Claim

Not applicable. The Authorization Code flow obtains user consent interactively at the authorization endpoint, so there is no separate anonymous-registration claim ceremony. (A claim ceremony would only apply to the unsupported `agent_auth` extension noted in Step 2.)

## Step 5 — Use the credential

The OAuth endpoints for this origin:

| Purpose | URL |
| --- | --- |
| Authorization | `https://kellytalkssocial.myteachify.com/oauth/authorize` |
| Token | `https://kellytalkssocial.myteachify.com/api/oauth/token` |
| UserInfo | `https://kellytalkssocial.myteachify.com/api/oauth/userinfo` |
| Revocation | `https://kellytalkssocial.myteachify.com/api/oauth/revoke` |
| Client registration (vendor platforms) | `https://kellytalkssocial.myteachify.com/api/oauth/register` |

1. Generate a PKCE `code_verifier` and its S256 `code_challenge`, then send the user to the authorization endpoint:

```
https://kellytalkssocial.myteachify.com/oauth/authorize?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&scope=openid%20profile%20account:read&resource=https%3A%2F%2Fkellytalkssocial.myteachify.com&code_challenge=CODE_CHALLENGE&code_challenge_method=S256&state=RANDOM
```

`scope` lists what the token may do (`account:read` unlocks the member's own data below; see the Scopes table). `resource` (RFC 8707) binds the token to this origin — the value the protected-resource metadata advertises — and must be repeated at the token endpoint.

2. On approval the user is redirected back with a `code` (and the `iss` parameter, RFC 9207). Exchange it for tokens:

```
curl -X POST https://kellytalkssocial.myteachify.com/api/oauth/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=authorization_code' \
  -d 'code=AUTHORIZATION_CODE' \
  -d 'redirect_uri=YOUR_REDIRECT_URI' \
  -d 'client_id=YOUR_CLIENT_ID' \
  -d 'code_verifier=CODE_VERIFIER' \
  -d resource=https://kellytalkssocial.myteachify.com
```

That exchange is the public-client form. A confidential client (the checkbox in Step 3 left checked) must also send `-d client_secret=YOUR_CLIENT_SECRET`; without it the token endpoint rejects the exchange.

3. Call the API with the returned access token:

```
curl https://kellytalkssocial.myteachify.com/api/oauth/userinfo \
  -H 'Authorization: Bearer ACCESS_TOKEN'
```

For the signed-in member's own data, query the Member API on this origin with the token from step 2 — it already carries `account:read` and is bound to this origin:

```
curl -X POST https://kellytalkssocial.myteachify.com/member/graphql \
  -H 'Authorization: Bearer ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"query":"{ viewer { id email enrolledCourses(first: 10) { nodes { slug expireDate hasLifetimeAccess lastWatchedLecture { lectureId positionSeconds } } } } }"}'
```

The `viewer` field is the core of the Member API. It carries `id`, `name`, `email`, `avatar`, `schools` (every site the viewer holds a role at, with the role names), four entitlement connections — `enrolledCourses`, `digitalProducts`, `consultingServices` and `eventRegistrations` — and `payments`, which is a different kind of thing and is described below.

Three of those answer *what the member has access to*. `consultingServices` is the exception and is not a purchase list at all: it returns the services offered against the courses delivered to the viewer. Under a service, `slots` is the recurring availability and `meetings` are the meetings already reserved on it — **everyone's, not the viewer's**, with no field saying which are theirs. Do not render either as "my bookings".

**The four are not order records**: there is no order number, coupon code, refund status or invoice on them, so do not build a billing history from them — build it from `payments` below, which carries all four.

`enrolledCourses` reaches the full curriculum, which is more than the example above shows. Each node exposes `sections`, each section its `lectures`, and each lecture `title`, `state` (`unread`, `read`, `completed`), `isLocked`, `unlockAt` and `duration` (seconds) — enough to render a syllabus with per-lecture progress without a second call. A course node also carries `detailProgress`, `expireDate`, `hasLifetimeAccess` and `lastWatchedLecture`.

`viewer.payments` is the billing history: a Relay connection over the viewer's own payments at the bound school, read with `account:read`, up to 50 nodes a page, newest first. It answers the questions the entitlement connections cannot — `tradeNo`, `amount`, `currency`, `paymentType`, `paidAt`, `paymentState`, `adjustments` (the coupon and promotion lines), `invoice`, `lineitems`, `installment`, and the refund set `refundingState` / `refundedAmount` / `refundedAt` / `refundRequestedAt`.

**One node is one payment, not one order.** Group by `tradeNo` — the public order identifier — when a purchase took more than one payment, and do not assume a row per purchase.

The list deliberately includes **unpaid and expired** rows, because a Taiwanese ATM or convenience-store buyer has not paid yet and needs the instructions back: for those, render `bankCode`, `bankName`, `codeNo` and `expiredAt`. An unfinished checkout in this list is the design, not a defect. A checkout abandoned before any payment method was chosen is held back only while it is still unpaid; once it expires it appears like any other expired row.

The Member API also exposes the signed-in member's notification inbox: `notifications`, `unreadNotificationCount` and `notificationSubscriptions` read with `account:read`, while the `markNotificationRead`, `markAllNotificationsRead` and `updateNotificationSubscription` mutations require `account:write`. **Those three mutations are everything `account:write` currently grants** — despite the name, no other member state is writable through it.

Member tokens are bound (RFC 8707) to this origin: pass `resource=https://kellytalkssocial.myteachify.com` when requesting the token, exactly as advertised by the protected-resource metadata. A user-bound member token works at both `/mcp` and `/member/graphql`. `/mcp` authenticates from introspection `sub` (the user id, matching UserInfo); `/member/graphql` authenticates from the token's resource owner and does not read that claim. `/mcp` rejects a token with no resource owner as not bound to a user. The Member API returns 401 `invalid_token` with a `resource_metadata` pointer if the token was issued for a different origin.

When the discovery document lists `id_token_signing_alg_values_supported` and `jwks_uri`, the authorization-code exchange with the `openid` scope also returns an `id_token` (ES256). Verify it as OpenID Connect Core specifies: `iss` equals `https://kellytalkssocial.myteachify.com` (this document's issuer), `aud` equals your `client_id`, `exp` is in the future (the token lives 10 minutes; allow only your usual clock skew), `nonce` echoes the value you sent to the authorization endpoint, and the signature validates against the key named by `kid` at `jwks_uri` on this origin. The `sub`, `email`, `name`, `picture`, `org_id` and `roles` claims match what UserInfo returns, so a verified `id_token` needs no UserInfo call. Refresh responses carry no `id_token`. If either field is absent (the JWKS is published before signing starts, so `jwks_uri` alone does not imply an `id_token`), do not rely on an `id_token` being present — use UserInfo instead.

Alongside the standard OAuth fields, a member token response carries `school_id` and `school_subdomain` (the site the token is bound to) and `created_at` (issuance, seconds since the epoch). They are stable and safe to depend on. `school_id` is the same value the Member API checks the token against, so a token whose `school_id` is absent will be refused as issued for a different organization — re-run the authorization-code flow to obtain one that carries it, because a refresh copies the field it already has.

Use the `refresh_token` grant at the token endpoint to obtain a new access token without re-prompting the user. **There is no introspection endpoint on this origin** — RFC 7662 introspection is for first-party resource servers and is not advertised in this origin's discovery document. Nothing hands the binding back, either: neither the `id_token` nor UserInfo carries a `resource` claim, and the `WWW-Authenticate` on a 401 names the origin you called, never the one the token is bound to. **Store the `resource` you sent alongside the token** and compare it against the origin you are about to call; the server cannot tell you after the fact.

## Step 6 — Errors

Endpoints return standard OAuth 2.0 error responses (RFC 6749 §5.2): a JSON body with an `error` code (e.g. `invalid_request`, `invalid_grant`, `invalid_client`, `invalid_scope`) and a human-readable `error_description`. A protected resource signals an invalid or malformed token with `401 invalid_token`, carrying a `WWW-Authenticate` header (see Step 1). A request with no token is served as anonymous by `/mcp` (public tools only) and challenged with `401 invalid_token` by `/member/graphql`, which has no anonymous mode.

The Member API refuses a scope at two different layers, and only one of them is an HTTP error. A token carrying **neither** `account:read` nor `account:write` never reaches the schema: the endpoint answers `403 insufficient_scope` with a `WWW-Authenticate` header. A token that holds one of them and asks for a field needing the other is already executing when the per-field check runs, so it gets `200 OK` with a GraphQL error in the `errors` array — for example `Insufficient scope. Required scope: account:write` — and **no** `WWW-Authenticate` header. **For that second case, match on the `errors` array, not the status code.**

`/mcp` answers `503 temporarily_unavailable` when it cannot reach a verdict on your token — a fault on our side, not a problem with the token. It carries no `WWW-Authenticate` header, because re-authorizing would not fix it: retry instead.

## Step 7 — Revocation

POST an access or refresh token to the revocation endpoint (`https://kellytalkssocial.myteachify.com/api/oauth/revoke`, RFC 7009) to invalidate it:

```
curl -X POST https://kellytalkssocial.myteachify.com/api/oauth/revoke \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'token=THE_TOKEN' \
  -d 'client_id=YOUR_CLIENT_ID' \
  -d 'client_secret=YOUR_CLIENT_SECRET'
```

A confidential client must send `client_secret`; without it the request is refused with `403 unauthorized_client`. A public client (the checkbox in Step 3 left unchecked) has no secret and omits the line.

What revocation does and does not reach, because multi-device support depends on it:

- **Post the refresh token to end a session.** The access token and its refresh token live in one record with one revocation stamp, so revoking either ends both — but only while the credential you post is itself still live. An access token past its lifetime (two hours for a member client) is no longer revocable, and RFC 7009 requires the endpoint to answer `200` for a token it cannot act on, so that call reports success while revoking nothing and the refresh token goes on minting new access tokens. The refresh token carries no such condition: revoking it always ends the pair.
- **Authorizing again does not revoke earlier tokens.** Every completed authorization adds a token pair, so the same member signed in on several devices keeps several live pairs. That is what makes multi-device work; it also means signing out on one device is your job, not a side effect.
- **A refresh keeps the old pair alive until the new *access* token is first used.** Rotation does not revoke the old record when the new pair is issued; it revokes it the first time the new access token is presented to an API. So a refresh response lost in transit can be retried with the old refresh token instead of logging the user out — but the old pair stops working the moment you use the new one, not when you next refresh.
- **Consent is shown on every authorization.** A member re-approves each time; do not treat a consent screen as evidence that the earlier grant is gone.

## Scopes

| Scope | Grants |
| --- | --- |
| `openid` | OpenID Connect authentication |
| `profile` | Basic profile information |
| `email` | Email address |
| `courses:read` | Read access to courses |
| `courses:write` | Write access to courses |
| `students:read` | Read access to students |
| `students:write` | Write access to students |
| `members:read` | Read access to members |
| `members:write` | Write access to members |
| `analytics:read` | Read access to analytics |
| `curriculum:read` | Read access to curriculum |
| `curriculum:write` | Write access to curriculum |
| `orders:read` | Read access to orders |
| `school:read` | Read access to school |
| `school:write` | Write access to school |
| `appearance:read` | Read access to appearance |
| `appearance:write` | Write access to appearance |
| `events:read` | Read access to events |
| `events:write` | Write access to events |
| `membership_plans:read` | Read access to membership plans |
| `membership_plans:write` | Write access to membership plans |
| `coupons:read` | Read access to coupons |
| `coupons:write` | Write access to coupons |
| `posts:read` | Read access to posts |
| `posts:write` | Write access to posts |
| `subscriptions:read` | Read access to subscriptions |
| `subscriptions:write` | Write access to subscriptions |
| `digital_products:read` | Read access to digital products |
| `digital_products:write` | Write access to digital products |
| `comments:read` | Read access to comments |
| `lecturers:read` | Read access to lecturers |
| `lecturers:write` | Write access to lecturers |
| `storage:write` | Write access to storage |
| `forms:read` | Read access to forms |
| `pages:read` | Read access to pages |
| `pages:write` | Write access to pages |
| `account:read` | Read access to account |
| `account:write` | Write access to account |

## Further reading

- OpenAPI specification for the REST API: [/openapi.json](https://kellytalkssocial.myteachify.com/openapi.json). It covers the REST endpoints only — the Member API is GraphQL at `https://kellytalkssocial.myteachify.com/member/graphql` and is not described there.
- Developer & agent documentation: [https://docs.loopwise.com](https://docs.loopwise.com)
