|
1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
2 | | -import { Effect } from "effect"; |
| 2 | +import { Effect, Predicate } from "effect"; |
3 | 3 |
|
4 | 4 | import { |
5 | 5 | AuthTemplateSlug, |
|
8 | 8 | OAuthClientSlug, |
9 | 9 | ToolName, |
10 | 10 | } from "./ids"; |
| 11 | +import { OAuthRegisterDynamicError } from "./oauth-client"; |
11 | 12 | import { definePlugin } from "./plugin"; |
12 | 13 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
13 | 14 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; |
@@ -143,4 +144,89 @@ describe("oauth.registerDynamicClient", () => { |
143 | 144 | }), |
144 | 145 | ), |
145 | 146 | ); |
| 147 | + |
| 148 | + // Regression: issue #770. Vercel (and other RFC 8252-strict servers) only |
| 149 | + // approve loopback redirect URIs for anonymous DCR, so a hosted/tailnet/LAN |
| 150 | + // origin trips `invalid_redirect_uri`. The failure must explain the loopback |
| 151 | + // requirement and name the offending URI, not dump the raw RFC code. |
| 152 | + it.effect("DCR rejection of a non-loopback redirect URI yields an actionable loopback hint", () => |
| 153 | + Effect.scoped( |
| 154 | + Effect.gen(function* () { |
| 155 | + const server = yield* serveOAuthTestServer({ |
| 156 | + scopes: ["read"], |
| 157 | + // Mirror Vercel: approve only loopback redirect URIs for anonymous DCR. |
| 158 | + approveRedirectUri: (uri) => |
| 159 | + uri.startsWith("http://localhost") || uri.startsWith("http://127."), |
| 160 | + }); |
| 161 | + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); |
| 162 | + yield* executor.acme.seed(); |
| 163 | + const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 164 | + |
| 165 | + const nonLoopback = "https://app.example.com/api/oauth/callback"; |
| 166 | + const error = yield* Effect.flip( |
| 167 | + executor.oauth.registerDynamicClient({ |
| 168 | + owner: "org", |
| 169 | + slug: CLIENT, |
| 170 | + registrationEndpoint: probe.registrationEndpoint!, |
| 171 | + authorizationUrl: probe.authorizationUrl, |
| 172 | + tokenUrl: probe.tokenUrl, |
| 173 | + resource: probe.resource, |
| 174 | + scopes: ["read"], |
| 175 | + tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, |
| 176 | + clientName: "Acme DCR", |
| 177 | + redirectUri: nonLoopback, |
| 178 | + originIntegration: INTEG, |
| 179 | + }), |
| 180 | + ); |
| 181 | + // Predicate guard narrows the union so `.message` reads off a typed failure. |
| 182 | + expect(Predicate.isTagged("OAuthRegisterDynamicError")(error)).toBe(true); |
| 183 | + const registerError = error as OAuthRegisterDynamicError; |
| 184 | + const message = registerError.message; |
| 185 | + // Names the loopback-only requirement, the localhost fix, and the URI. |
| 186 | + expect(message).toContain("loopback"); |
| 187 | + expect(message).toContain("http://localhost"); |
| 188 | + expect(message).toContain(nonLoopback); |
| 189 | + }), |
| 190 | + ), |
| 191 | + ); |
| 192 | + |
| 193 | + // The loopback hint is gated on the redirect URI actually being non-loopback. |
| 194 | + // A server that rejects even a loopback URI keeps the generic message so we |
| 195 | + // never tell the user "use localhost" when they already are. |
| 196 | + it.effect( |
| 197 | + "DCR rejection of a loopback redirect URI keeps the generic message (no false hint)", |
| 198 | + () => |
| 199 | + Effect.scoped( |
| 200 | + Effect.gen(function* () { |
| 201 | + const server = yield* serveOAuthTestServer({ |
| 202 | + scopes: ["read"], |
| 203 | + approveRedirectUri: () => false, // reject every redirect URI, even loopback |
| 204 | + }); |
| 205 | + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); |
| 206 | + yield* executor.acme.seed(); |
| 207 | + const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 208 | + |
| 209 | + const error = yield* Effect.flip( |
| 210 | + executor.oauth.registerDynamicClient({ |
| 211 | + owner: "org", |
| 212 | + slug: CLIENT, |
| 213 | + registrationEndpoint: probe.registrationEndpoint!, |
| 214 | + authorizationUrl: probe.authorizationUrl, |
| 215 | + tokenUrl: probe.tokenUrl, |
| 216 | + resource: probe.resource, |
| 217 | + scopes: ["read"], |
| 218 | + tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, |
| 219 | + clientName: "Acme DCR", |
| 220 | + redirectUri: "http://127.0.0.1:5394/api/oauth/callback", |
| 221 | + originIntegration: INTEG, |
| 222 | + }), |
| 223 | + ); |
| 224 | + expect(Predicate.isTagged("OAuthRegisterDynamicError")(error)).toBe(true); |
| 225 | + const registerError = error as OAuthRegisterDynamicError; |
| 226 | + const message = registerError.message; |
| 227 | + expect(message).toContain("Dynamic Client Registration failed: invalid_redirect_uri"); |
| 228 | + expect(message).not.toContain("Automatic OAuth setup failed"); |
| 229 | + }), |
| 230 | + ), |
| 231 | + ); |
146 | 232 | }); |
0 commit comments