OAuth 2.1 + Dynamic Client Registration

OAuth

Storylayer's OAuth implementation is built on the OAuth 2.1 draft with mandatory PKCE, refresh-token rotation, and RFC-7591 dynamic client registration so Claude.ai, ChatGPT, and any hosted AI tool can self-register.

Discovery

Two well-known endpoints describe the auth surface — both return JSON and require no auth.

  • /.well-known/oauth-authorization-server — RFC 8414 metadata (issuer, endpoints, supported grant types, PKCE methods).
  • /.well-known/oauth-protected-resource — RFC 9728 metadata (resource URI + the authorization servers that can issue tokens for it).
curl https://app.storylayer.ai/.well-known/oauth-authorization-server

{
  "issuer": "https://app.storylayer.ai",
  "authorization_endpoint": "https://app.storylayer.ai/oauth/authorize",
  "token_endpoint":         "https://app.storylayer.ai/oauth/token",
  "registration_endpoint":  "https://app.storylayer.ai/oauth/register",
  "revocation_endpoint":    "https://app.storylayer.ai/oauth/revoke",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "scopes_supported": [...]
}

Dynamic Client Registration

Hosted MCP clients (Claude.ai, ChatGPT) self-register against POST /oauth/register per RFC 7591. We issue public clients only — no client_secret — and require PKCE on every authorization.

POST /oauth/register
content-type: application/json

{
  "client_name": "Acme AI Agent",
  "redirect_uris": ["https://acme.example/oauth/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_method": "none",
  "scope": "stories:read stories:write moments:read"
}

→ 201
{
  "client_id": "cli_...",
  "client_id_issued_at": 17...,
  "redirect_uris": [...],
  "grant_types": ["authorization_code","refresh_token"],
  "token_endpoint_auth_method": "none",
  "scope": "stories:read stories:write moments:read"
}

Authorization code flow (with PKCE)

  1. Generate a code_verifier (43–128 char random string) and a code_challenge = base64url(sha256(verifier)).
  2. Send the user to /oauth/authorize:
https://app.storylayer.ai/oauth/authorize?
  response_type=code&
  client_id=cli_...&
  redirect_uri=https%3A%2F%2Facme.example%2Foauth%2Fcallback&
  scope=stories%3Aread%20stories%3Awrite%20moments%3Aread&
  state=opaque-csrf-string&
  code_challenge=...base64url(sha256(verifier))...&
  code_challenge_method=S256
  1. The user signs in (if not already), reviews the requested scopes, and approves. We redirect back with ?code=…&state=….
  2. Exchange the code for tokens:
POST /oauth/token
content-type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=sl_oac_...
&redirect_uri=https%3A%2F%2Facme.example%2Foauth%2Fcallback
&client_id=cli_...
&code_verifier=...

→ 200
{
  "access_token":  "sl_oat_...",
  "token_type":    "Bearer",
  "expires_in":    3600,
  "refresh_token": "sl_ort_...",
  "scope":         "stories:read stories:write moments:read"
}

Use the access token on /api/v1/* and /api/mcp exactly like a PAT.

Refresh + revoke

Refresh tokens rotate on every exchange — the response always includes a new refresh token, and the previous one is invalidated. If a refresh fails because the token was already used, treat it as a security event and force the user to re-auth.

POST /oauth/token
grant_type=refresh_token
&refresh_token=sl_ort_...
&client_id=cli_...

→ 200
{
  "access_token":  "sl_oat_...",
  "token_type":    "Bearer",
  "expires_in":    3600,
  "refresh_token": "sl_ort_..."  // NEW token, store this
}

Revoke a token (RFC 7009):

POST /oauth/revoke
content-type: application/x-www-form-urlencoded

token=sl_oat_...
&token_type_hint=access_token
&client_id=cli_...

→ 200 (always, even when the token was already invalid)

Security notes

  • PKCE with S256 is required on every authorization. plain is not supported.
  • Redirect URIs are matched exactly. Wildcards and partial matches are rejected.
  • Authorization codes are single-use and expire in 60 seconds.
  • Access tokens expire in 60 minutes by default and can be revoked instantly from the dashboard or via /oauth/revoke.
  • Refresh tokens rotate on use; replay attempts are logged and the entire grant is invalidated.