diff --git a/server/src/nest/oauth/oauth-sdk.provider.ts b/server/src/nest/oauth/oauth-sdk.provider.ts index b304cf77c..c5b9b9c2a 100644 --- a/server/src/nest/oauth/oauth-sdk.provider.ts +++ b/server/src/nest/oauth/oauth-sdk.provider.ts @@ -6,6 +6,7 @@ import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types'; import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients'; import { InvalidClientMetadataError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors'; import { OauthService } from './oauth.service'; +import { classifyRedirectUri } from './oauth.helpers'; import { AuditService } from '../audit/audit.service'; import { ALL_SCOPES, DEFAULT_CLIENT_SCOPES } from '../../mcp/scopes'; import { getMcpSafeUrl } from '../../app-config'; @@ -21,26 +22,21 @@ import { getMcpSafeUrl } from '../../app-config'; */ // --------------------------------------------------------------------------- -// Redirect URI validation (mirrors oauth.ts DCR checks) +// Redirect URI validation // --------------------------------------------------------------------------- -const DANGEROUS_SCHEMES = new Set([ - 'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:', -]); - +// DCR runs this and then createOAuthClient's own check; both now decide through +// the one policy in oauth.helpers, which is what closes #2227. The three error +// strings stay byte-identical: DCR clients surface error_description verbatim. function assertValidRedirectUris(uris: string[]): void { for (const u of uris) { - let url: URL; - try { url = new URL(u); } catch { + const verdict = classifyRedirectUri(u); + if (verdict === 'malformed') throw new InvalidClientMetadataError(`Invalid redirect URI: ${u}`); - } - if (DANGEROUS_SCHEMES.has(url.protocol)) + if (verdict === 'dangerous') throw new InvalidClientMetadataError(`Dangerous redirect URI scheme: ${u}`); - if (url.protocol === 'https:') continue; - if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')) continue; - const scheme = url.protocol.slice(0, -1); - if (/^[a-z][a-z0-9+.-]*$/i.test(scheme) && scheme.includes('.')) continue; - throw new InvalidClientMetadataError('redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme'); + if (verdict === 'not_allowed') + throw new InvalidClientMetadataError('redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme'); } } diff --git a/server/src/nest/oauth/oauth.helpers.ts b/server/src/nest/oauth/oauth.helpers.ts index 49933506f..2d87b2c05 100644 --- a/server/src/nest/oauth/oauth.helpers.ts +++ b/server/src/nest/oauth/oauth.helpers.ts @@ -36,6 +36,116 @@ export function parseSqliteUtc(ts: string | null | undefined): Date | null { export const CODE_CHALLENGE_RE = /^[A-Za-z0-9_-]{43}$/; export const CODE_VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/; +// --------------------------------------------------------------------------- +// Redirect URI validation (RFC 8252) +// --------------------------------------------------------------------------- + +/** + * The three loopback hosts of RFC 8252 §7.3, and exactly the set the MCP SDK's + * authorize handler matches on. Registration and authorization have to agree on + * this list or a client registers a URI it can never authorize with (#2227). + */ +export const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** + * Schemes a browser must never be redirected to: script-executing + * (javascript:, vbscript:, data:), local-resource (file:, filesystem:, + * resource:, jar:, view-source:, blob:, about:), and extension or OS handlers + * that reach code outside the page (chrome-extension:, moz-extension:, + * safari-extension:, ms-msdt:, which is the Follina handler). + * + * Most of these were only ever blocked as a side effect of the old "a custom + * scheme must contain a dot" rule; dropping that rule to admit workbuddy:// + * (#2227) would otherwise have admitted them too. `intent:` is denied with + * them: Chrome follows its S.browser_fallback_url parameter, which turns any + * accepted intent:// redirect into an open redirect. Android apps that need a + * redirect register a private-use scheme or an https App Link instead. + */ +const DANGEROUS_REDIRECT_SCHEMES = new Set([ + 'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:', + 'view-source:', 'filesystem:', 'resource:', 'jar:', 'moz-extension:', 'safari-extension:', + 'chrome-search:', 'chrome-untrusted:', 'ms-browser-extension:', 'ms-msdt:', 'android-app:', + 'intent:', 'search-ms:', 'shell:', 'microsoft-edge:', 'itms-services:', +]); + +/** + * Everything under `ms-` is a Windows protocol handler (ms-settings:, + * ms-search:, ms-officecmd:, …), never a redirect target, and the namespace + * grows with every Windows release, so deny the prefix rather than chase it. + * Microsoft's own identity redirects use msauth:/msal…: and stay allowed. + */ +const DANGEROUS_SCHEME_PREFIX = 'ms-'; + +/** Transports that are not browser navigations, so never a redirect target. */ +const NON_REDIRECT_SCHEMES = new Set([ + 'ws:', 'wss:', 'ftp:', 'ftps:', 'mailto:', 'tel:', 'sms:', 'urn:', +]); + +export type RedirectUriVerdict = 'ok' | 'malformed' | 'dangerous' | 'not_allowed'; + +/** + * The single redirect_uri policy for both registration paths, DCR + * (POST /oauth/register) and the settings UI (POST /api/oauth/clients), which + * used to disagree: DCR refused every single-label private-use scheme while the + * settings route accepted javascript://localhost/… (#2227). + * + * Decisions are made on `url.protocol`, never on the raw string: the WHATWG + * parser lowercases the scheme and strips tabs, newlines and leading spaces, so + * 'JavaScript:', 'java\tscript:' and ' javascript:' all normalise into the + * deny-list instead of sliding past it. + */ +export function classifyRedirectUri(uri: string): RedirectUriVerdict { + let url: URL; + try { url = new URL(uri); } catch { return 'malformed'; } + + const protocol = url.protocol; + if (DANGEROUS_REDIRECT_SCHEMES.has(protocol) || protocol.startsWith(DANGEROUS_SCHEME_PREFIX)) return 'dangerous'; + if (NON_REDIRECT_SCHEMES.has(protocol)) return 'not_allowed'; + if (protocol === 'https:') return 'ok'; + if (protocol === 'http:') return LOOPBACK_HOSTS.has(url.hostname) ? 'ok' : 'not_allowed'; + + // Private-use schemes (RFC 8252 §7.1). The reverse-domain form is a SHOULD, + // not a MUST, and requiring a dot bought nothing: 'javascript.evil://x' + // passed it while 'workbuddy://…' did not. Two characters minimum so a + // Windows path (c:\tmp\cb) is not read as a scheme. + const scheme = protocol.slice(0, -1); + return /^[a-z][a-z0-9+.-]+$/i.test(scheme) ? 'ok' : 'not_allowed'; +} + +/** + * Byte-exact, with the one relaxation RFC 8252 §7.3 requires: a native client + * registers a loopback URI with a placeholder port and then authorizes on the + * port the OS handed it, so the port (and only the port) is ignored when both + * sides are loopback HTTP. The MCP SDK's authorize handler already relaxes it; + * TREK's matcher did not, so the SDK redirected to consent and the validate + * route then answered invalid_redirect_uri for the same request (#2227). + * + * Nothing else is normalised anywhere in the chain: the stored URI is compared + * as it was registered, percent-encoding included. + */ +export function redirectUriMatches(allowed: string, requested: string): boolean { + if (allowed === requested) return true; + + let a: URL, r: URL; + try { + a = new URL(allowed); + r = new URL(requested); + } catch { return false; } + + if (a.protocol !== 'http:' || r.protocol !== 'http:') return false; + if (!LOOPBACK_HOSTS.has(a.hostname) || !LOOPBACK_HOSTS.has(r.hostname)) return false; + // Only the port is free. The RFC does not allow localhost↔127.0.0.1 either, and + // userinfo and fragment are compared too: the URL parser would otherwise drop + // them silently and let 'http://attacker@127.0.0.1:1/cb#x' match a bare + // 'http://127.0.0.1:8080/cb'. + return a.hostname === r.hostname + && a.pathname === r.pathname + && a.search === r.search + && a.hash === r.hash + && a.username === r.username + && a.password === r.password; +} + // --------------------------------------------------------------------------- // DB row types // --------------------------------------------------------------------------- diff --git a/server/src/nest/oauth/oauth.service.ts b/server/src/nest/oauth/oauth.service.ts index 98f3e76de..972f189a5 100644 --- a/server/src/nest/oauth/oauth.service.ts +++ b/server/src/nest/oauth/oauth.service.ts @@ -20,10 +20,12 @@ import { CODE_VERIFIER_RE, REFRESH_ROTATION_GRACE_MS, REFRESH_TOKEN_TTL_MS, + classifyRedirectUri, generateAccessToken, generateRefreshToken, hashToken, parseSqliteUtc, + redirectUriMatches, timingSafeEqualHex, type OAuthClientRow, type OAuthTokenRow, @@ -118,16 +120,15 @@ export class OauthService { if (!isMachineClient && (!redirectUris || redirectUris.length === 0)) return { error: 'At least one redirect URI is required', status: 400 }; if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 }; + // Same policy as the DCR path (#2227). This used to exempt any host named + // localhost or 127.0.0.1 regardless of scheme, which let the settings UI + // register javascript://localhost/…, and the consent flow then assigns that + // stored URI to window.location.href. for (const uri of redirectUris) { - let parsed: URL; - try { - parsed = new URL(uri); - } catch { - return { error: `Invalid redirect URI: ${uri}`, status: 400 }; - } - if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') { - return { error: `Redirect URI must use HTTPS (localhost exempt): ${uri}`, status: 400 }; - } + const verdict = classifyRedirectUri(uri); + if (verdict === 'malformed') return { error: `Invalid redirect URI: ${uri}`, status: 400 }; + if (verdict === 'dangerous') return { error: `Dangerous redirect URI scheme: ${uri}`, status: 400 }; + if (verdict === 'not_allowed') return { error: `Redirect URI must use HTTPS, loopback HTTP, or a private custom scheme: ${uri}`, status: 400 }; } if (!allowedScopes || allowedScopes.length === 0) return { error: 'At least one scope is required', status: 400 }; @@ -615,7 +616,12 @@ export class OauthService { } const allowedUris: string[] = JSON.parse(client.redirect_uris); - if (!params.redirect_uri || !allowedUris.includes(params.redirect_uri)) { + // Exact match except for the loopback port, which RFC 8252 §7.3 leaves to + // the OS, and which the SDK's authorize handler already relaxes, so a + // native client got a 302 to consent and an invalid_redirect_uri from this + // route for one and the same request (#2227). + const requestedUri = params.redirect_uri; + if (!requestedUri || !allowedUris.some(allowed => redirectUriMatches(allowed, requestedUri))) { return { valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri does not match any registered URI' }; } diff --git a/server/tests/integration/oauth-authorize.test.ts b/server/tests/integration/oauth-authorize.test.ts index 3217e8f5f..c0fd8a886 100644 --- a/server/tests/integration/oauth-authorize.test.ts +++ b/server/tests/integration/oauth-authorize.test.ts @@ -161,6 +161,27 @@ describe('GET /oauth/authorize — SDK authorizationHandler over trekOAuthProvid expect(loc.searchParams.get('state')).toBeNull(); }); + it('AUTHZ-002B: a private-use scheme registers and authorizes with the byte-identical URI (#2227)', async () => { + const redirectUri = 'workbuddy://workbuddy/mcp/connector%3A/oauth/callback'; + const clientId = await registerClient(redirectUri); + const { challenge } = makePkce(); + + const res = await request(app).get('/oauth/authorize').query({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope: 'trips:read', + code_challenge: challenge, + code_challenge_method: 'S256', + }); + + expect(res.status).toBe(302); + const loc = new URL(res.headers.location); + expect(`${loc.origin}${loc.pathname}`).toBe('https://trek.example.com/oauth/consent'); + // Forwarded unchanged: the %3A must survive to the token exchange. + expect(loc.searchParams.get('redirect_uri')).toBe(redirectUri); + }); + it('AUTHZ-003 — wrong resource 302-redirects back to the client with invalid_target', async () => { const clientId = await registerClient(); const { challenge } = makePkce(); diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts index 30f8725a1..8d0b61a1d 100644 --- a/server/tests/integration/oauth.test.ts +++ b/server/tests/integration/oauth.test.ts @@ -165,6 +165,43 @@ describe('DCR scope optional — ChatGPT compatibility (issue #959 bug 2)', () = }); }); +// DCR runs two validators in sequence: the clients store's own check and +// createOAuthClient's. Every unit test of the first one stubs the second away, +// which is how both of these shipped rejected (#2227). +describe('DCR redirect URI policy (issue #2227)', () => { + it('OAUTH-2227A: POST /oauth/register accepts the [::1] loopback URI', async () => { + const res = await request(app) + .post('/oauth/register') + .set('Content-Type', 'application/json') + .send({ redirect_uris: ['http://[::1]:8080/oauth/callback'], token_endpoint_auth_method: 'none', scope: 'trips:read' }); + expect(res.status).toBe(201); + expect(res.body.redirect_uris).toEqual(['http://[::1]:8080/oauth/callback']); + }); + + it('OAUTH-2227B: POST /oauth/register accepts a single-label private-use scheme, unmodified', async () => { + const uri = 'workbuddy://workbuddy/mcp/connector%3A/oauth/callback'; + const res = await request(app) + .post('/oauth/register') + .set('Content-Type', 'application/json') + .send({ redirect_uris: [uri], token_endpoint_auth_method: 'none', scope: 'trips:read' }); + expect(res.status).toBe(201); + // Stored verbatim: the authorize and token endpoints compare it byte for byte. + expect(res.body.redirect_uris).toEqual([uri]); + }); + + // The SDK's own metadata schema only blocks javascript:, data: and vbscript:, + // so these two are TREK's to refuse, one per verdict branch. + it('OAUTH-2227C: POST /oauth/register still refuses dangerous and non-navigable schemes on a loopback host', async () => { + for (const uri of ['blob://localhost/x', 'ws://localhost/cb']) { + const res = await request(app) + .post('/oauth/register') + .set('Content-Type', 'application/json') + .send({ redirect_uris: [uri], token_endpoint_auth_method: 'none', scope: 'trips:read' }); + expect(res.status).toBe(400); + } + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // Platform/discovery parity pins — byte-level oracles for the MCP/OAuth mount // migration (these must pass identically before AND after the routes move @@ -641,6 +678,60 @@ describe('GET /api/oauth/authorize/validate', () => { expect(res.body.error).toBe('unsupported_response_type'); }); + it('OAUTH-2227D: a loopback client that registered a placeholder port validates on the port the OS gave it', async () => { + // The reporter's shape end to end: register http://[::1]:8080/oauth/callback, + // then authorize from the ephemeral port the OS actually handed the client. + // Before #2227 registration refused the URI outright; once it was accepted, + // this route still answered invalid_redirect_uri because it compared byte + // for byte while RFC 8252 §7.3 frees the port for loopback redirects. + const { user } = createUser(testDb); + const { challenge } = makePkce(); + const reg = await request(app) + .post('/oauth/register') + .set('Content-Type', 'application/json') + .send({ redirect_uris: ['http://[::1]:8080/oauth/callback'], token_endpoint_auth_method: 'none', scope: 'trips:read' }); + expect(reg.status).toBe(201); + + const res = await request(app) + .get('/api/oauth/authorize/validate') + .set('Cookie', authCookie(user.id)) + .query({ + response_type: 'code', + client_id: reg.body.client_id, + redirect_uri: 'http://[::1]:54321/oauth/callback', + scope: 'trips:read', + code_challenge: challenge, + code_challenge_method: 'S256', + }); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(true); + }); + + it('OAUTH-2227E: the port is all that is free, so a different callback path is still refused', async () => { + const { user } = createUser(testDb); + const { challenge } = makePkce(); + const reg = await request(app) + .post('/oauth/register') + .set('Content-Type', 'application/json') + .send({ redirect_uris: ['http://127.0.0.1:8080/oauth/callback'], token_endpoint_auth_method: 'none', scope: 'trips:read' }); + expect(reg.status).toBe(201); + + const res = await request(app) + .get('/api/oauth/authorize/validate') + .set('Cookie', authCookie(user.id)) + .query({ + response_type: 'code', + client_id: reg.body.client_id, + redirect_uri: 'http://127.0.0.1:54321/stolen', + scope: 'trips:read', + code_challenge: challenge, + code_challenge_method: 'S256', + }); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(false); + expect(res.body.error).toBe('invalid_redirect_uri'); + }); + it('OAUTH-021 — returns 200 with valid:false for missing PKCE', async () => { const res = await request(app) .get('/api/oauth/authorize/validate') diff --git a/server/tests/unit/nest/oauth-redirect-uri.test.ts b/server/tests/unit/nest/oauth-redirect-uri.test.ts new file mode 100644 index 000000000..41238befb --- /dev/null +++ b/server/tests/unit/nest/oauth-redirect-uri.test.ts @@ -0,0 +1,134 @@ +/** + * The shared redirect_uri policy (#2227). Both registration paths (DCR through + * the SDK clients store and the settings UI through createOAuthClient) used to + * carry their own copy of it and disagreed in both directions: DCR refused every + * single-label private-use scheme, the settings route accepted + * javascript://localhost/…. These cases pin the one policy they now share. + */ +import { describe, it, expect } from 'vitest'; +import { classifyRedirectUri, redirectUriMatches, type RedirectUriVerdict } from '../../../src/nest/oauth/oauth.helpers'; + +describe('classifyRedirectUri', () => { + const cases: Array<[string, RedirectUriVerdict]> = [ + // https and loopback http + ['https://a.example.com/cb', 'ok'], + ['https://a.example.com:8443/cb?x=1#f', 'ok'], + ['http://localhost:8080/cb', 'ok'], + ['http://127.0.0.1/cb', 'ok'], + ['http://[::1]/cb', 'ok'], + ['http://[::1]:8080/oauth/callback', 'ok'], + ['http://evil.example.com/cb', 'not_allowed'], + ['http://127.0.0.1.evil.com/cb', 'not_allowed'], + + // private-use schemes, with and without the reverse-domain form + ['com.example.app:/oauth2redirect', 'ok'], + ['workbuddy://workbuddy/mcp/connector%3A/oauth/callback', 'ok'], + ['myapp://cb', 'ok'], + ['msauth://com.x.y/abc', 'ok'], + + // dangerous schemes, including the ones a localhost host used to smuggle past + // the settings route's hostname exemption + ['javascript:alert(1)', 'dangerous'], + ['javascript://localhost/%0aalert(1)', 'dangerous'], + ['blob://localhost/x', 'dangerous'], + ['about://localhost/x', 'dangerous'], + ['data:text/html,', 'dangerous'], + ['vbscript:msgbox(1)', 'dangerous'], + ['file:///etc/passwd', 'dangerous'], + ['view-source:https://x', 'dangerous'], + ['filesystem:https://x/temporary/y', 'dangerous'], + ['jar:https://x!/y', 'dangerous'], + ['resource://x/y', 'dangerous'], + ['chrome://settings', 'dangerous'], + ['chrome-extension://abc/page.html', 'dangerous'], + ['chrome-untrusted://x/y', 'dangerous'], + ['moz-extension://a/b', 'dangerous'], + ['safari-extension://a/b', 'dangerous'], + ['android-app://com.x.y/https/host/path', 'dangerous'], + ['intent://x#Intent;S.browser_fallback_url=https%3A%2F%2Fevil.test;end', 'dangerous'], + // ms-msdt is Follina; the prefix rule covers the rest of the Windows handlers + ['ms-msdt:/id PCWDiagnostic', 'dangerous'], + ['ms-browser-extension://a/b', 'dangerous'], + ['ms-settings:windowsupdate', 'dangerous'], + ['ms-officecmd://x', 'dangerous'], + + // transports that are not browser navigations + ['ws://localhost/cb', 'not_allowed'], + ['wss://localhost/cb', 'not_allowed'], + ['ftp://localhost/x', 'not_allowed'], + ['ftps://localhost/x', 'not_allowed'], + ['mailto:me@example.com', 'not_allowed'], + ['tel:+15550000', 'not_allowed'], + ['sms:+15550000', 'not_allowed'], + ['urn:ietf:wg:oauth:2.0:oob', 'not_allowed'], + + // malformed + ['not a url', 'malformed'], + ['', 'malformed'], + ['/relative/cb', 'malformed'], + ]; + + it.each(cases)('RURI-001: %s → %s', (uri, expected) => { + expect(classifyRedirectUri(uri)).toBe(expected); + }); + + it('RURI-002: decides on the parsed protocol, so case and whitespace tricks normalise into the deny-list', () => { + for (const uri of ['JavaScript:alert(1)', 'java\tscript:alert(1)', ' javascript:alert(1)', 'JAVASCRIPT:alert(1)']) { + expect(classifyRedirectUri(uri)).toBe('dangerous'); + } + }); + + it('RURI-004: OS protocol handlers are denied whether or not they carry the ms- prefix', () => { + // The prefix rule catches the ms-* namespace; these four are the well-known + // siblings that sit outside it and would otherwise read as private-use. + for (const uri of ['search-ms:query=x', 'shell:startup', 'microsoft-edge:https://evil.test', 'itms-services://?url=x']) { + expect(classifyRedirectUri(uri)).toBe('dangerous'); + } + // Microsoft's own identity redirects have no hyphen and stay allowed. + expect(classifyRedirectUri('msauth://com.x.y/abc')).toBe('ok'); + }); + + it('RURI-003: a single-letter scheme is not a private-use scheme', () => { + // 'c:\\tmp\\cb' parses with protocol 'c:', which is a Windows path, not a redirect. + expect(classifyRedirectUri('c:\\tmp\\cb')).toBe('not_allowed'); + }); +}); + +describe('redirectUriMatches', () => { + it('RURI-010: byte-exact matches win, including percent-encoding', () => { + const uri = 'workbuddy://workbuddy/mcp/connector%3A/oauth/callback'; + expect(redirectUriMatches(uri, uri)).toBe(true); + expect(redirectUriMatches(uri, 'workbuddy://workbuddy/mcp/connector:/oauth/callback')).toBe(false); + expect(redirectUriMatches('https://a.example.com/cb', 'https://a.example.com/cb/')).toBe(false); + }); + + it('RURI-011: a loopback redirect may land on any port (RFC 8252 §7.3)', () => { + expect(redirectUriMatches('http://127.0.0.1:8080/callback', 'http://127.0.0.1:54321/callback')).toBe(true); + expect(redirectUriMatches('http://[::1]:8080/oauth/callback', 'http://[::1]:54321/oauth/callback')).toBe(true); + expect(redirectUriMatches('http://localhost/cb?x=1', 'http://localhost:9999/cb?x=1')).toBe(true); + }); + + it('RURI-012: only the port is free, so path, query and host still match exactly', () => { + expect(redirectUriMatches('http://127.0.0.1:8080/callback', 'http://127.0.0.1:54321/other')).toBe(false); + expect(redirectUriMatches('http://127.0.0.1:8080/cb?x=1', 'http://127.0.0.1:54321/cb?x=2')).toBe(false); + // The RFC does not allow localhost↔127.0.0.1 cross-matching. + expect(redirectUriMatches('http://localhost:8080/cb', 'http://127.0.0.1:54321/cb')).toBe(false); + }); + + it('RURI-013: non-loopback and non-http URIs stay byte-exact', () => { + expect(redirectUriMatches('https://a.example.com:8443/cb', 'https://a.example.com:9443/cb')).toBe(false); + expect(redirectUriMatches('http://evil.example.com:80/cb', 'http://evil.example.com:81/cb')).toBe(false); + expect(redirectUriMatches('https://localhost:8443/cb', 'https://localhost:9443/cb')).toBe(false); + expect(redirectUriMatches('myapp://localhost:1/cb', 'myapp://localhost:2/cb')).toBe(false); + }); + + it('RURI-015: userinfo and fragment are compared, not silently dropped', () => { + expect(redirectUriMatches('http://127.0.0.1:8080/cb', 'http://attacker@127.0.0.1:54321/cb')).toBe(false); + expect(redirectUriMatches('http://127.0.0.1:8080/cb', 'http://127.0.0.1:54321/cb#evil')).toBe(false); + }); + + it('RURI-014: an unparseable side never matches', () => { + expect(redirectUriMatches('not a url', 'http://127.0.0.1:1/cb')).toBe(false); + expect(redirectUriMatches('http://127.0.0.1:1/cb', 'not a url')).toBe(false); + }); +}); diff --git a/server/tests/unit/nest/oauth-sdk.provider.test.ts b/server/tests/unit/nest/oauth-sdk.provider.test.ts index df707575b..06431e885 100644 --- a/server/tests/unit/nest/oauth-sdk.provider.test.ts +++ b/server/tests/unit/nest/oauth-sdk.provider.test.ts @@ -141,23 +141,38 @@ describe('TrekClientsStore.registerClient', () => { it('SDKP-011: rejects dangerous schemes', async () => { await expect(store().registerClient({ redirect_uris: ['javascript:alert(1)'] } as never)) .rejects.toThrowError(new InvalidClientMetadataError('Dangerous redirect URI scheme: javascript:alert(1)')); + // A host named localhost does not make the scheme safe (#2227). + for (const uri of ['javascript://localhost/%0aalert(1)', 'blob://localhost/x', 'about://localhost/x']) { + await expect(store().registerClient({ redirect_uris: [uri] } as never)) + .rejects.toThrowError(new InvalidClientMetadataError(`Dangerous redirect URI scheme: ${uri}`)); + } }); - it('SDKP-012: rejects plain-http non-loopback and dot-less custom schemes', async () => { + it('SDKP-012: rejects plain-http non-loopback and non-navigable schemes', async () => { const msg = 'redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme'; - await expect(store().registerClient({ redirect_uris: ['http://evil.example.com/cb'] } as never)) - .rejects.toThrowError(new InvalidClientMetadataError(msg)); - await expect(store().registerClient({ redirect_uris: ['myapp://cb'] } as never)) - .rejects.toThrowError(new InvalidClientMetadataError(msg)); + for (const uri of ['http://evil.example.com/cb', 'ws://localhost/cb']) { + await expect(store().registerClient({ redirect_uris: [uri] } as never)) + .rejects.toThrowError(new InvalidClientMetadataError(msg)); + } + // These were only ever refused as a side effect of the old dot rule. + for (const uri of ['view-source:https://x', 'moz-extension://a/b']) { + await expect(store().registerClient({ redirect_uris: [uri] } as never)) + .rejects.toThrowError(new InvalidClientMetadataError(`Dangerous redirect URI scheme: ${uri}`)); + } }); - it('SDKP-013: accepts https, loopback http, and reverse-DNS custom schemes', async () => { + it('SDKP-013: accepts https, loopback http, and private-use custom schemes', async () => { for (const uri of [ 'https://a.example.com/cb', 'http://localhost:8080/cb', 'http://127.0.0.1/cb', 'http://[::1]/cb', 'com.example.app:/oauth', + // #2227: single-label private-use schemes are legal (RFC 8252 §7.1 only + // SHOULDs the reverse-domain form) and were rejected outright. + 'workbuddy://workbuddy/mcp/connector%3A/oauth/callback', + 'myapp://cb', + 'msauth://com.x.y/abc', ]) { const oauth = makeOauth(); await new TrekClientsStore(oauth).registerClient({ diff --git a/server/tests/unit/nest/oauth.service.test.ts b/server/tests/unit/nest/oauth.service.test.ts index a92654af9..9d59c6df8 100644 --- a/server/tests/unit/nest/oauth.service.test.ts +++ b/server/tests/unit/nest/oauth.service.test.ts @@ -207,6 +207,41 @@ describe('createOAuthClient', () => { expect(result.client).toBeDefined(); }); + it('allows the http://[::1] loopback redirect URI (#2227)', () => { + const { user } = createUser(testDb); + const result = createOAuthClient(user.id, 'Test', ['http://[::1]:8080/callback'], ['trips:read']); + expect(result.error).toBeUndefined(); + expect(result.client).toBeDefined(); + }); + + it('allows a private-use custom scheme (#2227)', () => { + const { user } = createUser(testDb); + const uri = 'workbuddy://workbuddy/mcp/connector%3A/oauth/callback'; + const result = createOAuthClient(user.id, 'Test', [uri], ['trips:read']); + expect(result.error).toBeUndefined(); + // The stored value must round-trip untouched: the whole authorize/token + // chain compares it byte for byte. + expect(result.client!.redirect_uris).toEqual([uri]); + }); + + it('rejects dangerous schemes that a localhost host used to smuggle through (#2227)', () => { + const { user } = createUser(testDb); + for (const uri of ['javascript://localhost/%0aalert(1)', 'blob://localhost/x', 'about://localhost/x']) { + const result = createOAuthClient(user.id, 'Test', [uri], ['trips:read']); + expect(result.status).toBe(400); + expect(result.error).toContain('Dangerous'); + } + }); + + it('rejects non-navigable schemes on a localhost host (#2227)', () => { + const { user } = createUser(testDb); + for (const uri of ['ftp://localhost/x', 'wss://localhost/x']) { + const result = createOAuthClient(user.id, 'Test', [uri], ['trips:read']); + expect(result.status).toBe(400); + expect(result.error).toContain('HTTPS'); + } + }); + it('returns 400 error if no scopes provided', () => { const { user } = createUser(testDb); const result = createOAuthClient(user.id, 'Test', ['https://example.com/cb'], []); @@ -623,6 +658,31 @@ describe('validateAuthorizeRequest', () => { expect(result.error).toBe('invalid_redirect_uri'); }); + it('accepts a loopback redirect on the port the OS handed the client (#2227)', () => { + const { user } = createUser(testDb); + const created = makeClient(user.id, { redirectUris: ['http://[::1]:8080/oauth/callback'] }); + const clientId = created.client!.client_id as string; + + const result = validateAuthorizeRequest( + makeParams({ client_id: clientId, redirect_uri: 'http://[::1]:54321/oauth/callback' }), + user.id + ); + expect(result.valid).toBe(true); + }); + + it('relaxes only the port of a loopback redirect, never the path (#2227)', () => { + const { user } = createUser(testDb); + const created = makeClient(user.id, { redirectUris: ['http://127.0.0.1:8080/oauth/callback'] }); + const clientId = created.client!.client_id as string; + + const result = validateAuthorizeRequest( + makeParams({ client_id: clientId, redirect_uri: 'http://127.0.0.1:54321/stolen' }), + user.id + ); + expect(result.valid).toBe(false); + expect(result.error).toBe('invalid_redirect_uri'); + }); + it('validates scope against client allowed_scopes', () => { const { user } = createUser(testDb); const created = makeClient(user.id, { scopes: ['trips:read'] }); diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 3fc02634d..9c5dcc9dd 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -172,7 +172,8 @@ const settings: TranslationStrings = { 'settings.oauth.modal.clientName': 'اسم التطبيق', 'settings.oauth.modal.clientNamePlaceholder': 'مثال: Claude Web، تطبيق MCP الخاص بي', 'settings.oauth.modal.redirectUris': 'عناوين URI لإعادة التوجيه', - 'settings.oauth.modal.redirectUrisHint': 'عنوان URI واحد لكل سطر. يُطلب HTTPS (localhost مستثنى). يُطبق تطابق دقيق.', + 'settings.oauth.modal.redirectUrisHint': + 'عنوان URI واحد لكل سطر. يُسمح بـ HTTPS أو HTTP محلي أو مخطط تطبيق خاص (myapp://). يُطبق تطابق دقيق، باستثناء منفذ العنوان المحلي.', 'settings.oauth.modal.scopes': 'النطاقات المسموح بها', 'settings.oauth.modal.scopesHint': 'list_trips وget_trip_summary متاحان دائماً — لا يُطلب نطاق. يساعدان الذكاء الاصطناعي في اكتشاف معرّفات الرحلات.', diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index 95f7aa613..7e74d2173 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -247,7 +247,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URIs de redirecionamento', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Uma URI por linha. HTTPS obrigatório (localhost isento). Correspondência exata.', + 'Uma URI por linha. HTTPS, HTTP em loopback ou um esquema de aplicativo próprio (myapp://). Correspondência exata, exceto a porta de uma URI em loopback.', 'settings.oauth.modal.scopes': 'Escopos permitidos', 'settings.oauth.modal.scopesHint': 'list_trips e get_trip_summary estão sempre disponíveis — sem escopo necessário. Permitem à IA descobrir IDs de viagem.', diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts index e5dca8f5b..549f47a85 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -142,7 +142,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URIs de redirecció', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Un URI per línia. HTTPS obligatori (localhost exempt). Coincidència exacta.', + 'Un URI per línia. HTTPS, HTTP en bucle local o un esquema propi com myapp://. Coincidència exacta, excepte el port en bucle local.', 'settings.oauth.modal.scopes': 'Àmbits permesos', 'settings.oauth.modal.scopesHint': 'list_trips i get_trip_summary sempre estan disponibles — sense àmbit requerit. Permeten a la IA descobrir els IDs de viatge necessaris.', diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index c6745f6dc..f4f059488 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -156,7 +156,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'Přesměrovací URI', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Jedno URI na řádek. Vyžadováno HTTPS (localhost vyjmuto). Vyžadována přesná shoda.', + 'Jedno URI na řádek. HTTPS, HTTP na loopbacku nebo vlastní schéma aplikace (myapp://). Přesná shoda, kromě portu u loopback URI.', 'settings.oauth.modal.scopes': 'Povolená oprávnění', 'settings.oauth.modal.scopesHint': 'list_trips a get_trip_summary jsou vždy dostupné — bez požadovaného oprávnění. Umožňují AI zjistit potřebná ID výletů.', diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index 14c031a88..0dbc8476f 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -160,7 +160,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'Redirect-URIs', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Eine URI pro Zeile. HTTPS erforderlich (localhost ausgenommen). Exakte Übereinstimmung erforderlich.', + 'Eine URI pro Zeile. HTTPS, Loopback-HTTP oder ein eigenes App-Schema (myapp://). Exakte Übereinstimmung, außer beim Port einer Loopback-URI.', 'settings.oauth.modal.scopes': 'Erlaubte Berechtigungen', 'settings.oauth.modal.scopesHint': 'list_trips und get_trip_summary sind immer verfügbar — keine Berechtigung nötig. Sie helfen der KI, Trip-IDs zu ermitteln.', diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index 8ab846faf..45c134186 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -204,7 +204,8 @@ const settings: TranslationStrings = { 'settings.oauth.modal.clientNamePlaceholder': 'e.g. Claude Web, My MCP App', 'settings.oauth.modal.redirectUris': 'Redirect URIs', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', - 'settings.oauth.modal.redirectUrisHint': 'One URI per line. HTTPS required (localhost exempt). Exact match enforced.', + 'settings.oauth.modal.redirectUrisHint': + 'One URI per line. HTTPS, loopback HTTP, or a private app scheme (myapp://). Matched exactly, except for the port of a loopback URI.', 'settings.oauth.modal.scopes': 'Allowed Scopes', 'settings.oauth.modal.scopesHint': 'list_trips and get_trip_summary are always available — no scope required. They let the AI discover trip IDs needed to use any other tool.', diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index 6b5e10799..9adabb68a 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -159,7 +159,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URIs de redirección', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Un URI por línea. HTTPS obligatorio (localhost exento). Coincidencia exacta.', + 'Un URI por línea. HTTPS, HTTP de bucle local o un esquema propio de la aplicación (myapp://). Coincidencia exacta, excepto el puerto de bucle local.', 'settings.oauth.modal.scopes': 'Ámbitos permitidos', 'settings.oauth.modal.scopesHint': 'list_trips y get_trip_summary siempre están disponibles — sin ámbito requerido. Permiten a la IA descubrir los IDs de viaje necesarios.', diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index f9a8c8371..c011b75ba 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -161,7 +161,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URIs de redirection', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Une URI par ligne. HTTPS requis (localhost exempté). Correspondance exacte.', + 'Une URI par ligne. HTTPS, HTTP en boucle locale ou un schéma applicatif privé (myapp://). Correspondance exacte, sauf le port en boucle locale.', 'settings.oauth.modal.scopes': 'Portées autorisées', 'settings.oauth.modal.scopesHint': "list_trips et get_trip_summary sont toujours disponibles — aucune portée requise. Ils permettent à l'IA de découvrir les IDs de voyage nécessaires.", diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index e096edef3..0600ed4be 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -204,7 +204,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'Redirect URIs', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Ένα URI ανά γραμμή. Απαιτείται HTTPS (το localhost εξαιρείται). Επιβάλλεται ακριβής αντιστοίχιση.', + 'Ένα URI ανά γραμμή. HTTPS, HTTP σε loopback ή ιδιωτικό σχήμα εφαρμογής (myapp://). Ακριβής αντιστοίχιση, εκτός από τη θύρα σε loopback URI.', 'settings.oauth.modal.scopes': 'Επιτρεπόμενα Scopes', 'settings.oauth.modal.scopesHint': 'Τα list_trips και get_trip_summary είναι πάντα διαθέσιμα — δεν απαιτείται scope. Επιτρέπουν στο AI να εντοπίζει τα trip IDs που χρειάζονται για τη χρήση οποιουδήποτε άλλου εργαλείου.', diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index e8a57cdab..fac367cb3 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -159,7 +159,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'Átirányítási URI-k', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Soronként egy URI. HTTPS szükséges (localhost kivételével). Pontos egyezés szükséges.', + 'Soronként egy URI. HTTPS, loopback HTTP vagy saját alkalmazásséma (myapp://). Pontos egyezés, kivéve a loopback URI portját.', 'settings.oauth.modal.scopes': 'Engedélyezett jogosultságok', 'settings.oauth.modal.scopesHint': 'A list_trips és get_trip_summary mindig elérhető — jogosultság nélkül. Segítenek az AI-nak megtalálni az utazás azonosítókat.', diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 7bc1b941c..d8252e027 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -192,7 +192,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'Redirect URI', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://aplikasiku.com/callback\nhttps://aplikasiku.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Satu URI per baris. HTTPS wajib (localhost dikecualikan). Kecocokan tepat diberlakukan.', + 'Satu URI per baris. HTTPS, HTTP loopback, atau skema aplikasi pribadi (myapp://). Kecocokan tepat, kecuali port pada URI loopback.', 'settings.oauth.modal.scopes': 'Cakupan yang Diizinkan', 'settings.oauth.modal.scopesHint': 'list_trips dan get_trip_summary selalu tersedia — tidak perlu cakupan. Keduanya memungkinkan AI menemukan ID perjalanan yang diperlukan untuk menggunakan alat lainnya.', diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index f315b7b83..3e416c1ad 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -159,7 +159,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URI di reindirizzamento', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Un URI per riga. HTTPS richiesto (localhost esente). Corrispondenza esatta richiesta.', + 'Un URI per riga. HTTPS, HTTP di loopback o uno schema privato come myapp://. Corrispondenza esatta, tranne la porta di loopback.', 'settings.oauth.modal.scopes': 'Ambiti consentiti', 'settings.oauth.modal.scopesHint': "list_trips e get_trip_summary sono sempre disponibili — nessun ambito richiesto. Permettono all'IA di scoprire gli ID viaggio necessari.", diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index 22ba43862..23659577d 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -181,7 +181,8 @@ const settings: TranslationStrings = { 'settings.oauth.modal.clientNamePlaceholder': '例:Claude Web', 'settings.oauth.modal.redirectUris': 'リダイレクトURI', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback', - 'settings.oauth.modal.redirectUrisHint': '1行につき1つ。HTTPS必須。', + 'settings.oauth.modal.redirectUrisHint': + '1行につき1つ。HTTPS、ループバックHTTP、または独自のアプリスキーム(myapp://)が使えます。ループバックURIのポート番号を除き、完全一致が必要です。', 'settings.oauth.modal.scopes': '許可スコープ', 'settings.oauth.modal.scopesHint': 'list_trips と get_trip_summary は常に利用可能です。', 'settings.oauth.modal.selectAll': 'すべて選択', diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index 712aab462..52335ca6f 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -192,7 +192,8 @@ const settings: TranslationStrings = { 'settings.oauth.modal.clientNamePlaceholder': '예: Claude Web, My MCP App', 'settings.oauth.modal.redirectUris': '리디렉션 URI', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', - 'settings.oauth.modal.redirectUrisHint': '한 줄에 URI 하나. HTTPS 필수 (localhost 예외). 정확히 일치해야 합니다.', + 'settings.oauth.modal.redirectUrisHint': + '한 줄에 URI 하나. HTTPS, 루프백 HTTP 또는 앱 전용 스킴(myapp://)을 쓸 수 있습니다. 루프백 URI의 포트를 빼고 정확히 일치해야 합니다.', 'settings.oauth.modal.scopes': '허용 권한 범위', 'settings.oauth.modal.scopesHint': 'list_trips 및 get_trip_summary는 항상 사용 가능합니다 — 권한 범위 불필요. AI가 다른 도구를 사용하는 데 필요한 여행 ID를 찾을 수 있습니다.', diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index 5cb97fd4e..99339c02d 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -158,7 +158,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': "Redirect-URI's", 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Eén URI per regel. HTTPS vereist (localhost uitgezonderd). Exacte overeenkomst vereist.', + 'Eén URI per regel. HTTPS, loopback-HTTP of een eigen app-schema (myapp://). Exacte overeenkomst, behalve de poort van een loopback-URI.', 'settings.oauth.modal.scopes': 'Toegestane rechten', 'settings.oauth.modal.scopesHint': "list_trips en get_trip_summary zijn altijd beschikbaar — geen recht vereist. Ze helpen de AI trip-ID's te ontdekken.", diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index 6975221db..4e2590a9b 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -154,7 +154,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URI przekierowania', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Jeden URI na linię. Wymagane HTTPS (localhost zwolniony). Wymagana dokładna zgodność.', + 'Jeden URI na linię. HTTPS, HTTP na loopbacku lub własny schemat aplikacji (myapp://). Dokładna zgodność, z wyjątkiem portu w URI loopback.', 'settings.oauth.modal.scopes': 'Dozwolone uprawnienia', 'settings.oauth.modal.scopesHint': 'list_trips i get_trip_summary są zawsze dostępne — bez wymaganych uprawnień. Umożliwiają AI odkrycie potrzebnych ID podróży.', diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index b39cd4338..cafd1b32b 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -157,7 +157,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URI перенаправления', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Один URI на строку. Требуется HTTPS (localhost исключён). Требуется точное совпадение.', + 'Один URI на строку. HTTPS, локальный HTTP или собственная схема приложения (myapp://). Требуется точное совпадение, кроме порта локального URI.', 'settings.oauth.modal.scopes': 'Разрешённые области доступа', 'settings.oauth.modal.scopesHint': 'list_trips и get_trip_summary всегда доступны — область не требуется. Они помогают ИИ находить нужные ID поездок.', diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index f442dafb6..11117e096 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -200,7 +200,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'Omdirigerings-URI:er', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://din-app.com/callback\nhttps://din-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'En URI per rad. HTTPS krävs (med undantag för localhost). Exakt matchning krävs.', + 'En URI per rad. HTTPS, loopback-HTTP eller ett eget app-schema (myapp://). Exakt matchning, utom porten i en loopback-URI.', 'settings.oauth.modal.scopes': 'Tillåtna tillämpningsområden', 'settings.oauth.modal.scopesHint': 'list_trips och get_trip_summary är alltid tillgängliga – inget tillämpningsområden krävs. De gör det möjligt för AI:n att hämta de res-ID:n som behövs för att använda andra verktyg.', diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index fc01041b0..729a05bd6 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -195,7 +195,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': "URI'leri Yönlendir", 'settings.oauth.modal.redirectUrisPlaceholder': 'https://uygulamaniz.com/callback\\nhttps://uygulamaniz.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Satır başına bir URI. HTTPS gerekli (yerel ana bilgisayar hariç). Tam eşleşme uygulandı.', + 'Satır başına bir URI. HTTPS, geri döngü HTTP veya kendi uygulama şeması (myapp://). Geri döngü URI portu dışında tam eşleşme gerekir.', 'settings.oauth.modal.scopes': 'İzin Verilen Kapsamlar', 'settings.oauth.modal.scopesHint': 'list_trips ve get_trip_summary her zaman kullanılabilir; kapsam gerektirmez. Yapay zekanın başka herhangi bir aracı kullanmak için gereken yolculuk kimliklerini keşfetmesine olanak tanıyorlar.', diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 3e7f923bb..9a3fc941f 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -159,7 +159,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URI перенаправлення', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Один URI на рядок. Потрібен HTTPS (localhost виключено). Потрібне точне співпадіння.', + 'Один URI на рядок. HTTPS, локальний HTTP або власна схема застосунку (myapp://). Потрібне точне співпадіння, окрім порту локального URI.', 'settings.oauth.modal.scopes': 'Дозволені області доступу', 'settings.oauth.modal.scopesHint': 'list_trips і get_trip_summary завжди доступні — область не потрібна. Вони допомагають ШІ знаходити потрібні ID поїздок.', diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index 7b84a2cea..cb0d69b42 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -201,7 +201,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URI chuyển hướng', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Một URI trên mỗi dòng. Yêu cầu HTTPS (miễn trừ localhost). Đối sánh chính xác được thực thi.', + 'Một URI trên mỗi dòng. HTTPS, HTTP loopback hoặc lược đồ ứng dụng riêng (myapp://). Đối sánh chính xác, ngoại trừ cổng của URI loopback.', 'settings.oauth.modal.scopes': 'Phạm vi được phép', 'settings.oauth.modal.scopesHint': 'list_trips và get_trip_summary luôn có sẵn — không yêu cầu phạm vi. Họ cho phép AI khám phá ID chuyến đi cần thiết để sử dụng bất kỳ công cụ nào khác.', diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index 493666d42..c584c8d9b 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -182,7 +182,8 @@ const settings: TranslationStrings = { 'settings.oauth.modal.clientNamePlaceholder': '例如 Claude Web、我的 MCP 應用程式', 'settings.oauth.modal.redirectUris': '重新導向 URI', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', - 'settings.oauth.modal.redirectUrisHint': '每行一個 URI。需要 HTTPS(localhost 除外)。需要完全符合。', + 'settings.oauth.modal.redirectUrisHint': + '每行一個 URI。支援 HTTPS、回送 HTTP 或自訂應用程式協定(myapp://)。需要完全符合,回送 URI 的連接埠除外。', 'settings.oauth.modal.scopes': '允許的授權範圍', 'settings.oauth.modal.scopesHint': 'list_trips 和 get_trip_summary 始終可用——不需要授權範圍。它們可幫助 AI 找到所需的行程 ID。', diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index 8bbeb4c2e..aee92e589 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -147,7 +147,8 @@ const settings: TranslationStrings = { 'settings.oauth.modal.clientNamePlaceholder': '例如 Claude Web、我的 MCP 应用', 'settings.oauth.modal.redirectUris': '重定向 URI', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', - 'settings.oauth.modal.redirectUrisHint': '每行一个 URI。需要 HTTPS(localhost 除外)。要求精确匹配。', + 'settings.oauth.modal.redirectUrisHint': + '每行一个 URI。支持 HTTPS、回环 HTTP 或自定义应用协议(myapp://)。要求精确匹配,回环 URI 的端口除外。', 'settings.oauth.modal.scopes': '允许的权限范围', 'settings.oauth.modal.scopesHint': 'list_trips 和 get_trip_summary 始终可用——无需权限范围。它们帮助 AI 发现所需的行程 ID。',