Skip to content

feat: sign in with AssemblyAI (OAuth) alongside API keys - #6

Merged
damafeez merged 10 commits into
mainfrom
aawoyemi/feat/PE-2656/oauth-sign-in
Oct 30, 2026
Merged

damafeez merged 10 commits into
mainfrom
aawoyemi/feat/PE-2656/oauth-sign-in

Conversation

@damafeez

@damafeez damafeez commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

What & why

Today an MCP client has to be configured with an AssemblyAI API key as its Bearer token. This lets clients that support MCP authorization (Claude, Cursor, MCP Inspector) add the server with no headers instead: the client signs the member in through AssemblyAI, and the server works out the API key itself. API keys keep working exactly as before.

How it works

  1. Challenge. A request with no token gets a 401 whose WWW-Authenticate names /.well-known/oauth-protected-resource. That metadata now names AssemblyAI's authorization server (ASSEMBLYAI_OAUTH_ISSUER) and the scopes aai:api and offline_access.
  2. Sign-in. The client registers itself with the authorization server and signs the member in through the consent page. It then sends the access token as the Bearer.
  3. Exchange. withOAuthExchange (src/oauth.ts) sits in front of withMcpAuth. When the Bearer is an access token (a JWT), it exchanges it at POST /api/v2/oauth/api-key for the API key of the member's connection to that client, then passes the request on with that key. withMcpAuth and the tools are unchanged.
  4. Server identity. The exchange also authenticates this server, by sending the deployment's Vercel OIDC token as X-AAI-Service-Token. AssemblyAI accepts only its own deployment of this project, so self-hosted copies use API keys.

Behaviour

  • Errors: a refused token answers 401 with a challenge, and a missing scope answers 403 insufficient_scope, so the client signs in again. An outage, or a missing service token, answers 503, so clients don't loop through sign-in for a problem sign-in can't fix. (mcp-handler turns anything verifyToken throws into a 401, which is why this sits in a wrapper rather than in verifyToken.)
  • Caching: keys are cached per access token for at most five minutes, never past the key's rotation or the token's expiry. A connection revoked from the AssemblyAI dashboard therefore stops working within minutes.
  • Logging: only hashes of tokens and keys are logged, never the values.

Configuration

All settings default to production, so a production deployment needs no new env vars. A preview that signs in against another AssemblyAI environment sets ASSEMBLYAI_OAUTH_ISSUER, ASSEMBLYAI_OAUTH_EXCHANGE_ORIGIN, ASSEMBLYAI_BASE_URL and ASSEMBLYAI_LLM_GATEWAY_BASE_URL together. They're documented in the README.

New dependency: @vercel/oidc 4.0.1, which reads the deployment's OIDC token.

How it was tested

  • npm run typecheck and npm run build pass.

  • npm test: the existing 101 smoke tests, plus 22 new stubbed exchange tests (src/oauth-test.ts). They cover:

    • API keys passing through, and access tokens being exchanged with both credentials sent;
    • the request body surviving the swapped header;
    • the cache limits: 5 minutes, the rotation time, and the token's expiry;
    • the 401, 403 and 503 mapping, and the metadata.
  • Locally: no token gets a 401 pointing at the metadata, and the metadata advertises the authorization server.

  • Sign-in against a preview with npm run test:oauth (src/oauth-preview-test.ts), using a preview configured for a non-production AssemblyAI environment and the protection-bypass header. All 8 checks passed:

    • the 401 challenge, the metadata, and the authorization server's metadata;
    • client registration, and PKCE sign-in sending resource;
    • tools/list with the access token, and a tool call whose exchanged key the AssemblyAI API accepts;
    • a tampered token refused with a challenge.

    Fixes from getting there, each with a test:

    • a refused service token answers 503, not a sign-in loop (5711447), and its claims are logged for diagnosis (b383675);
    • the exchanged key is passed on without Request's copy constructor, which throws on Next's request class on Vercel (eb9dd10).

🤖 Generated with Claude Code

MCP clients can now add the server with no headers and sign the member
in through AssemblyAI's authorization server. The protected resource
metadata advertises it, and a Bearer that is an OAuth access token is
exchanged for the API key of the member's connection
(POST /api/v2/oauth/api-key, with the deployment's Vercel OIDC token as
X-AAI-Service-Token) before withMcpAuth sees the request, so the tools
are unchanged. A Bearer that is an API key still passes through.

- Refused tokens answer 401 (403 for a missing scope) with a challenge,
  so clients sign in again; an outage answers 503, which doesn't.
- Keys are cached per access token for at most five minutes and never
  past the key's rotation or the token's expiry, so a revoked
  connection is noticed within minutes.
- ASSEMBLYAI_OAUTH_ISSUER and ASSEMBLYAI_OAUTH_EXCHANGE_ORIGIN default
  to production; a deployment against another environment sets them
  with ASSEMBLYAI_BASE_URL and ASSEMBLYAI_LLM_GATEWAY_BASE_URL.
- npm test adds stubbed exchange tests; npm run test:oauth signs in
  against a deployment like an MCP client (protection bypass header
  supported for previews).
- .gitignore: .vercel and .env* (local Vercel link and pulled env).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
assemblyai-mcp Ready Ready Preview Oct 30, 2026 10:34am UTC

Request Review

Comment thread src/oauth.ts Resolved
AssemblyAI answers "Unknown client" (401) when it doesn't accept this
deployment's Vercel OIDC token: the wrong deployment, or its OIDC
settings not in place yet. That isn't the member's token, so passing it
on as a 401 challenge would send the client through sign-in forever.
It's now a 503, logged as service_token_refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issuer, audience, project and environment are what AssemblyAI matches
against its settings; logging them (never the token) makes a refusal
diagnosable from the deployment's logs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The credential cache uses a truncated 48-bit hash, allowing a colliding token—even an invalid JWT—to receive another member’s cached API key before validation.

3 open issues
What changed in this PR

Adds AssemblyAI OAuth sign-in to the MCP server alongside existing API-key authentication, exchanging access tokens for connection-specific API keys before requests reach the tools.

Changes:

  • Adds token exchange, bounded caching, and authentication-error handling.
  • Advertises OAuth discovery metadata and integrates the exchange wrapper into the MCP route.
  • Adds offline and live-preview tests, deployment documentation, and the Vercel OIDC dependency.
File Description
src/​oauth.ts Implements token exchange, caching, and OAuth responses.
src/​oauth-test.ts Adds stubbed exchange and metadata tests.
src/​oauth-preview-test.ts Adds browser-based sign-in and deployed MCP checks.
README.md Documents sign-in, configuration, and testing.
package.json Adds OAuth test scripts and the OIDC dependency.
package-lock.json Locks the new dependency and its transitive packages.
app/​mcp/​[[...path]]/​route.ts Wraps MCP authentication with OAuth exchange.
app/​.well-known/​oauth-protected-resource/​route.ts Advertises the authorization server and scopes.
.gitignore Excludes Vercel state and environment files.

Review effort: Balanced

Comment thread src/oauth.ts Resolved
Comment thread src/oauth-preview-test.ts Resolved
Comment thread src/oauth.ts Resolved
damafeez and others added 3 commits October 29, 2026 18:22
On Vercel the incoming request is Next's own Request subclass, from a
different copy of the fetch implementation than the global Request, and
new Request(req, init) read its private fields and threw ("Cannot read
private member #state") right after a successful exchange. The request
is now rebuilt from its public parts: URL, method, headers, body (half
duplex) and signal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Key the exchange cache by the access token's full SHA-256, not the
  12-character log hash, so no other token can match a cached entry.
  Note in the code that the cache is shared across requests on purpose.
- A successful exchange whose body is JSON null (or empty, or without a
  key) answers 503 instead of throwing.
- The preview test now requires AssemblyAI's "transcript id not found"
  error for its probe, which only an accepted key gets back, rather than
  merely the absence of an auth error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The OAuth exchange lacks a deadline through response-body consumption, so an AssemblyAI stall can cause deployment timeouts instead of the intended 503 with Retry-After.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Bound AssemblyAI response reads and map timeouts to unavailable

src/​oauth.ts:155

The exchange has no deadline for fetching or reading the response body. If AssemblyAI stalls, uncached OAuth requests can reach the deployment timeout instead of returning the intended 503 with Retry-After. Add a bounded abort signal that remains active through body consumption, and map timeouts to the unavailable outcome.

3 resolved since last review

Review effort: Balanced

The exchange response carries the connection's id, which is not a
secret and is stable for the connection's life, so oauth_exchange_ok
now logs it: it joins these logs to AssemblyAI's own, its audit log and
the member's Connected apps without matching timestamps. tokenHash is
now computed only after the exchange, next to the logs that use it, so
a cache hit doesn't hash the token twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unsanitized upstream error descriptions can make authentication challenge headers throw a TypeError, causing server errors and preventing clients from restarting sign-in.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Restrict Bearer challenge description to printable ASCII

src/​oauth.ts:276

An upstream 401/403 detail containing a newline or a non-Latin-1 character (for example, This app’s access was revoked) survives scrubForLog, but cannot be used directly as an HTTP header value. In both cases, constructing this response throws a TypeError, turning the intended authentication challenge into a server error and preventing the client from restarting sign-in. Restrict the header description to the printable ASCII characters permitted in a Bearer challenge; the JSON body can retain the original description.

Review effort: Balanced

- The exchange gives up after 10 seconds (AbortSignal.timeout, which
  also bounds reading the body) and answers 503, logged as "timeout",
  instead of holding the request until the function times out.
- The WWW-Authenticate description keeps printable ASCII only. An
  upstream detail with a newline or a curly apostrophe made new Response
  throw, turning the sign-in challenge into a 500. The JSON body keeps
  the original wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@damafeez

Copy link
Copy Markdown
Contributor Author

Addressed both items from Copilot's latest review summaries in 639119c.

  • Exchange deadline: the exchange now gives up after 10 seconds and answers 503. It uses AbortSignal.timeout on the fetch, which also bounds reading the response body, and logs reason: "timeout". Test 8c stalls the exchange and expects the 503; without the deadline it fails at its own 2-second limit rather than hanging.
  • Challenge header: the WWW-Authenticate description now keeps printable ASCII only, and the JSON body keeps the original wording. Test 6b uses a detail with a curly apostrophe and a newline. Without the fix, building the response throws Cannot convert argument to a ByteString … 8217; with it, the client gets a 401 with an ASCII challenge.

npm test: 101 + 30 pass, and typecheck is clean.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The OAuth error handler hides body-read timeouts on 401/403 responses, returning a client-facing 401 instead of 503 and potentially prompting sign-in for a server-side failure.

1 open issue

Review effort: Balanced

Comment thread src/oauth.ts Resolved
The exchange response body is now read once, before the status is
looked at. A read that fails (the deadline firing mid-body, a dropped
connection) answers 503, logged as "timeout" or "body_read", instead of
being treated as a body that isn't JSON: before, a 401 whose body
stalled became a sign-in challenge, and lost an "Unknown client" detail
that should have been a 503.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human assessment is needed because an unpublished Bearer-token parsing issue can cause valid credentials with multiple spaces to bypass OAuth exchange and reach API-key authentication unchanged.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

The header was split on single spaces, so "Bearer  <token>" read as an
empty token: a valid access token skipped the exchange and got a 401,
sending the client back through sign-in. The token is now taken after
any run of whitespace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@damafeez

Copy link
Copy Markdown
Contributor Author

Copilot's last pass noted an unpublished issue: Bearer followed by more than one space. Fixed in a015ab3. bearerOf now takes the token after any run of whitespace (/^bearer\s+(\S+)\s*$/i), so Bearer <token> is exchanged like Bearer <token> instead of skipping the exchange and getting a 401. New test 3d fails on the old split(" ") parsing.

@damafeez
damafeez requested a balanced review from Copilot October 30, 2026 10:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The new authentication path relies on external OAuth verification and production trust policies unavailable in this checkout, requiring human confirmation of audience/resource binding and deployment service-token allowlisting.

No open issues 🎉

Review effort: Balanced

@damafeez

Copy link
Copy Markdown
Contributor Author

Copilot's last pass found no open issues. It asks a human to confirm two things that are enforced outside this repo, in AssemblyAI's accounts service, rather than in this server:

  • Audience and resource binding. The exchange verifies the access token's signature, issuer, audience (AssemblyAI's Stytch project) and expiry, requires the aai:api scope, and resolves the member from the token's subject and organization. This server never trusts the token itself; it only forwards it.
  • Service-token allowlisting. The X-AAI-Service-Token (this deployment's Vercel OIDC token) must be signed by AssemblyAI's Vercel team, with the expected audience, project_id equal to this project, and environment matching the configured one: production in production, preview only against a non-production environment. A refusal answers 503 here (service_token_refused), never a sign-in challenge.

Both paths were exercised end to end against a non-production environment (npm run test:oauth, 8/8), including AssemblyAI refusing this server's identity before its settings were in place.

@damafeez
damafeez merged commit cb71979 into main Oct 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants