You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat: sign in with AssemblyAI (OAuth) alongside API keys - #6
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
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.
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.
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.
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/oidc4.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).
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>
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>
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.
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.
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>
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
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.
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>
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
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.
- 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>
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.
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.
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>
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.
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>
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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
WWW-Authenticatenames/.well-known/oauth-protected-resource. That metadata now names AssemblyAI's authorization server (ASSEMBLYAI_OAUTH_ISSUER) and the scopesaai:apiandoffline_access.withOAuthExchange(src/oauth.ts) sits in front ofwithMcpAuth. When the Bearer is an access token (a JWT), it exchanges it atPOST /api/v2/oauth/api-keyfor the API key of the member's connection to that client, then passes the request on with that key.withMcpAuthand the tools are unchanged.X-AAI-Service-Token. AssemblyAI accepts only its own deployment of this project, so self-hosted copies use API keys.Behaviour
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-handlerturns anythingverifyTokenthrows into a 401, which is why this sits in a wrapper rather than inverifyToken.)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_URLandASSEMBLYAI_LLM_GATEWAY_BASE_URLtogether. They're documented in the README.New dependency:
@vercel/oidc4.0.1, which reads the deployment's OIDC token.How it was tested
npm run typecheckandnpm run buildpass.npm test: the existing 101 smoke tests, plus 22 new stubbed exchange tests (src/oauth-test.ts). They cover: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:resource;tools/listwith the access token, and a tool call whose exchanged key the AssemblyAI API accepts;Fixes from getting there, each with a test:
5711447), and its claims are logged for diagnosis (b383675);Request's copy constructor, which throws on Next's request class on Vercel (eb9dd10).🤖 Generated with Claude Code