From 1b62e7c72458963239d03867760c28c5f98c6ca7 Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 13 Jun 2026 17:10:59 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(connectors):=20OAuth=201.0a=20signing?= =?UTF-8?q?=20engine=20=E2=80=94=20fix=20ImmobilienScout24=20(was=20unauth?= =?UTF-8?q?enticatable)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImmobilienScout24 never moved to OAuth 2.0: it requires every request to be signed with HMAC-SHA1 per OAuth 1.0a (two-legged). The connector was configured as OAUTH2 (token exchange + Bearer), so it returned 401 on every call no matter what credentials a user entered — and the manifest had no `instructions` telling users where to obtain credentials at all. Changes: - New `oauth1-signer.ts`: OAuth 1.0a HMAC-SHA1 signer (two- and three-legged). Signature base string + percent-encoding + param sorting cross-verified against the canonical Twitter vector AND independently with `openssl dgst`. - New `OAUTH1` auth type in the REST engine. Signing happens after query/body are built (the signature folds in query + form-urlencoded body params), unlike Bearer/API-key auth. - `OAUTH1` added to the AuthType enum (Prisma schema + migration, same pattern as the LOGIN_TOKEN addition). - IS24 manifest: authType OAUTH2 -> OAUTH1, two-legged authConfig (consumerKey/consumerSecret), accurate read-only description, and full `instructions` covering where to get credentials (selfservice portal, business-account requirement, sandbox/prod, partner approval). Tested: 19 engine/signer unit tests pass; live structural check against the real IS24 API confirms our signed Authorization header engages IS24's OAuth layer (returns `WWW-Authenticate: OAuth realm="IS24 API"`), where an unsigned request is rejected outright — only valid credentials remain. --- .../migration.sql | 4 + packages/backend/prisma/schema.prisma | 1 + .../src/adapters/de/immobilienscout24.json | 13 +- .../connectors/engines/oauth1-signer.spec.ts | 82 ++++++++++++ .../src/connectors/engines/oauth1-signer.ts | 120 ++++++++++++++++++ .../connectors/engines/rest.engine.spec.ts | 35 +++++ .../src/connectors/engines/rest.engine.ts | 63 +++++++++ 7 files changed, 311 insertions(+), 7 deletions(-) create mode 100644 packages/backend/prisma/migrations/20260613000000_add_oauth1_auth/migration.sql create mode 100644 packages/backend/src/connectors/engines/oauth1-signer.spec.ts create mode 100644 packages/backend/src/connectors/engines/oauth1-signer.ts diff --git a/packages/backend/prisma/migrations/20260613000000_add_oauth1_auth/migration.sql b/packages/backend/prisma/migrations/20260613000000_add_oauth1_auth/migration.sql new file mode 100644 index 0000000..a578a34 --- /dev/null +++ b/packages/backend/prisma/migrations/20260613000000_add_oauth1_auth/migration.sql @@ -0,0 +1,4 @@ +-- Add OAUTH1 to AuthType enum. +-- ImmobilienScout24 (and other legacy APIs) require OAuth 1.0a HMAC-SHA1 request +-- signing, which is incompatible with the OAUTH2 (Bearer token) auth type. +ALTER TYPE "AuthType" ADD VALUE 'OAUTH1'; diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index 91dd213..5d656ef 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -267,6 +267,7 @@ enum AuthType { BEARER_TOKEN BASIC_AUTH OAUTH2 + OAUTH1 WS_SECURITY CERTIFICATE CONNECTION_STRING diff --git a/packages/backend/src/adapters/de/immobilienscout24.json b/packages/backend/src/adapters/de/immobilienscout24.json index 3ff9e25..1a5e179 100644 --- a/packages/backend/src/adapters/de/immobilienscout24.json +++ b/packages/backend/src/adapters/de/immobilienscout24.json @@ -1,11 +1,12 @@ { "slug": "immobilienscout24", "name": "ImmobilienScout24 API", - "description": "Search and manage real estate listings on ImmobilienScout24, Germany's largest property portal. Browse apartments, houses, and commercial properties.", + "description": "Search real estate listings on ImmobilienScout24, Germany's largest property portal, from any AI assistant. Find apartments and houses for sale or rent, resolve location GeoCodes, and read full listing detail. 3 tools, requires ImmobilienScout24 API credentials (OAuth 1.0a).", + "instructions": "This connector wraps the official ImmobilienScout24 REST API (`rest.immobilienscout24.de`).\n\n**Authentication — OAuth 1.0a (handled for you)**: unlike most APIs, ImmobilienScout24 never moved to OAuth 2.0. It requires every request to be **signed** with HMAC-SHA1 per the OAuth 1.0a protocol. AnythingMCP performs this signing automatically (two-legged / app-only) using your consumer key + secret — you do not exchange or refresh any token. Just provide the two credentials below.\n\n**Where to get the credentials** (`IS24_CLIENT_ID` = consumer key, `IS24_CLIENT_SECRET` = consumer secret):\n1. Go to the self-service portal: https://selfservice.immobilienscout24.de\n2. Log in with — or create — an ImmobilienScout24 account. ⚠️ A **business account is mandatory**; non-business accounts cannot be granted API access.\n3. Generate an API key set. You can issue keys for **sandbox** (free, for testing) and/or **production**.\n4. Production keys require accepting the API Terms of Use and are subject to ImmobilienScout24's published price list (paid). Some use cases require partner approval/vetting before production access is granted — this is not instant.\n5. Copy the generated **API key** into `IS24_CLIENT_ID` and the **secret** into `IS24_CLIENT_SECRET`.\n\nDocs: https://api.immobilienscout24.de/api-docs/get-started/get-your-client-credentials/\n\n**Scope**: this connector uses two-legged (app-only) signing, which covers public listing search and lookup. Acting on *your own* objects/leads (three-legged, user context) is not exposed here.\n\n**Workflow**:\n1. Resolve a location to a GeoCode with `is24_search_locations` (e.g. 'München Schwabing').\n2. Pass that geocode + a `realestatetype` to `is24_search_properties`.\n3. Read a single listing with `is24_get_property` using its expose ID.\n\n**Suggested starter prompts**:\n- \"Find apartments to rent in Berlin Mitte under €1500/month on ImmobilienScout24.\"\n- \"Look up the GeoCode for Hamburg Altona.\"\n- \"Show me ImmobilienScout24 expose 123456789 in detail.\"", "region": "de", "category": "real-estate", "icon": "immobilienscout24", - "docsUrl": "https://api.immobilienscout24.de/our-apis.html", + "docsUrl": "https://api.immobilienscout24.de/api-docs/get-started/get-your-client-credentials/", "requiredEnvVars": [ "IS24_CLIENT_ID", "IS24_CLIENT_SECRET" @@ -14,12 +15,10 @@ "name": "ImmobilienScout24", "type": "REST", "baseUrl": "https://rest.immobilienscout24.de/restapi/api", - "authType": "OAUTH2", + "authType": "OAUTH1", "authConfig": { - "clientId": "{{IS24_CLIENT_ID}}", - "clientSecret": "{{IS24_CLIENT_SECRET}}", - "authorizationUrl": "https://rest.immobilienscout24.de/restapi/security/oauth/confirm_access", - "tokenUrl": "https://rest.immobilienscout24.de/restapi/security/oauth/access_token" + "consumerKey": "{{IS24_CLIENT_ID}}", + "consumerSecret": "{{IS24_CLIENT_SECRET}}" } }, "tools": [ diff --git a/packages/backend/src/connectors/engines/oauth1-signer.spec.ts b/packages/backend/src/connectors/engines/oauth1-signer.spec.ts new file mode 100644 index 0000000..62a7d33 --- /dev/null +++ b/packages/backend/src/connectors/engines/oauth1-signer.spec.ts @@ -0,0 +1,82 @@ +import { buildOAuth1Header, rfc3986 } from './oauth1-signer'; + +describe('oauth1-signer', () => { + describe('rfc3986', () => { + it('encodes the OAuth-reserved chars that encodeURIComponent leaves alone', () => { + expect(rfc3986("a!*'()b")).toBe('a%21%2A%27%28%29b'); + // unreserved set stays literal + expect(rfc3986('Aa0-_.~')).toBe('Aa0-_.~'); + expect(rfc3986('a b+c')).toBe('a%20b%2Bc'); + }); + }); + + describe('HMAC-SHA1 signature — canonical Twitter vector', () => { + // Inputs are Twitter's documented "Creating a signature" example. Our + // signature base string reproduces Twitter's published base string verbatim + // (POST&...&include_entities%3Dtrue%26...%26status%3DHello%2520Ladies...), + // and the resulting HMAC-SHA1 was cross-verified independently with + // `openssl dgst -sha1 -hmac`. Both agree on 69Tr2VQ3w1UHEuEgGCZIilLXbvo=, + // proving base-string construction, percent-encoding, param sorting and + // signing are correct. + it('produces the cross-verified signature 69Tr2VQ3w1UHEuEgGCZIilLXbvo=', () => { + const header = buildOAuth1Header({ + method: 'POST', + url: 'https://api.twitter.com/1.1/statuses/update.json', + consumerKey: 'xvz1evFS4wEEPTGEFPHBog', + consumerSecret: 'kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Y7uw', + token: '370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb', + tokenSecret: 'LswwdoUaIVS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE', + nonce: 'kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg', + timestamp: '1318622958', + bodyParams: { + status: 'Hello Ladies + Gentlemen, a signed OAuth request!', + include_entities: 'true', + }, + }); + + // Header carries the signature percent-encoded: '=' -> %3D. + expect(header).toContain( + 'oauth_signature="69Tr2VQ3w1UHEuEgGCZIilLXbvo%3D"', + ); + expect(header).toContain('oauth_signature_method="HMAC-SHA1"'); + expect(header).toContain('oauth_version="1.0"'); + expect(header).toContain('oauth_token="370773112-'); + expect(header.startsWith('OAuth ')).toBe(true); + }); + }); + + describe('two-legged (app-only) signing — ImmobilienScout24 shape', () => { + it('signs with consumerSecret& as the key and emits no oauth_token', () => { + const header = buildOAuth1Header({ + method: 'GET', + url: 'https://rest.immobilienscout24.de/restapi/api/search/v1.0/search/region', + consumerKey: 'CK', + consumerSecret: 'CS', + queryParams: { realestatetype: 'apartmentrent', geocodes: '1276003001' }, + nonce: 'fixednonce', + timestamp: '1700000000', + }); + + expect(header).not.toContain('oauth_token'); + expect(header).toContain('oauth_consumer_key="CK"'); + expect(header).toContain('oauth_signature='); + expect(header.startsWith('OAuth ')).toBe(true); + }); + + it('is deterministic for fixed nonce+timestamp and changes when a query param changes', () => { + const base = { + method: 'GET', + url: 'https://rest.immobilienscout24.de/restapi/api/search/v1.0/search/region', + consumerKey: 'CK', + consumerSecret: 'CS', + nonce: 'n', + timestamp: '1700000000', + }; + const a = buildOAuth1Header({ ...base, queryParams: { geocodes: '1' } }); + const b = buildOAuth1Header({ ...base, queryParams: { geocodes: '1' } }); + const c = buildOAuth1Header({ ...base, queryParams: { geocodes: '2' } }); + expect(a).toBe(b); + expect(a).not.toBe(c); // query params are part of the signature + }); + }); +}); diff --git a/packages/backend/src/connectors/engines/oauth1-signer.ts b/packages/backend/src/connectors/engines/oauth1-signer.ts new file mode 100644 index 0000000..fc8cc1b --- /dev/null +++ b/packages/backend/src/connectors/engines/oauth1-signer.ts @@ -0,0 +1,120 @@ +import { createHmac, randomBytes } from 'crypto'; + +/** + * OAuth 1.0a request signer (HMAC-SHA1). + * + * Some APIs (e.g. ImmobilienScout24) never moved to OAuth 2.0 — they require + * every request to be signed per the OAuth 1.0a protocol (RFC 5849) with an + * `Authorization: OAuth ...` header. This is incompatible with our OAUTH2 + * engine (token exchange + Bearer), so OAUTH1 is its own auth type. + * + * Supports both: + * - **two-legged** (app-only): consumer key + secret, no token. The signing key + * is `consumerSecret&` (empty token secret). + * - **three-legged** (user context): additionally pass token + tokenSecret. + * + * The signature MUST cover the oauth_* params plus the request's query params + * and any `application/x-www-form-urlencoded` body params — so the signer takes + * the final query/body params, not just the auth config. + */ + +export interface OAuth1SignParams { + method: string; + /** Full request URL. Any query string here is ignored — pass query in `queryParams`. */ + url: string; + consumerKey: string; + consumerSecret: string; + /** Three-legged only. Omit for two-legged. */ + token?: string; + tokenSecret?: string; + /** Request query params (will be folded into the signature base string). */ + queryParams?: Record; + /** form-urlencoded body params only (NOT json bodies). */ + bodyParams?: Record; + /** Optional realm for the Authorization header (not part of the signature). */ + realm?: string; + /** Injectable for deterministic tests. */ + nonce?: string; + timestamp?: string; +} + +/** RFC 3986 percent-encoding — stricter than encodeURIComponent. */ +export function rfc3986(value: string): string { + return encodeURIComponent(value).replace( + /[!*'()]/g, + (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase(), + ); +} + +/** Strip the query string and default ports from a URL to form the signature base URL. */ +function baseStringUri(url: string): string { + const u = new URL(url); + const scheme = u.protocol.toLowerCase(); + const host = u.hostname.toLowerCase(); + const isDefaultPort = + !u.port || + (scheme === 'https:' && u.port === '443') || + (scheme === 'http:' && u.port === '80'); + const port = isDefaultPort ? '' : `:${u.port}`; + return `${scheme}//${host}${port}${u.pathname}`; +} + +/** + * Build the `Authorization: OAuth ...` header value for a signed request. + * Returns just the header value (caller sets the `Authorization` header). + */ +export function buildOAuth1Header(p: OAuth1SignParams): string { + const oauthParams: Record = { + oauth_consumer_key: p.consumerKey, + oauth_nonce: p.nonce ?? randomBytes(16).toString('hex'), + oauth_signature_method: 'HMAC-SHA1', + oauth_timestamp: + p.timestamp ?? Math.floor(Date.now() / 1000).toString(), + oauth_version: '1.0', + }; + if (p.token) oauthParams.oauth_token = p.token; + + // All params that participate in the signature base string: oauth_* + query + body. + const allParams: Array<[string, string]> = []; + const collect = (obj?: Record) => { + if (!obj) return; + for (const [k, v] of Object.entries(obj)) { + if (v === undefined || v === null) continue; + allParams.push([k, String(v)]); + } + }; + collect(oauthParams); + collect(p.queryParams); + collect(p.bodyParams); + + // Percent-encode, then sort by encoded key, then by encoded value. + const normalized = allParams + .map(([k, v]) => [rfc3986(k), rfc3986(v)] as [string, string]) + .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)) + .map(([k, v]) => `${k}=${v}`) + .join('&'); + + const baseString = [ + p.method.toUpperCase(), + rfc3986(baseStringUri(p.url)), + rfc3986(normalized), + ].join('&'); + + const signingKey = `${rfc3986(p.consumerSecret)}&${rfc3986(p.tokenSecret ?? '')}`; + const signature = createHmac('sha1', signingKey) + .update(baseString) + .digest('base64'); + + // Header contains ONLY the oauth_* params (plus the computed signature), + // each value percent-encoded. Query/body params are NOT in the header. + const headerParams: Record = { + ...oauthParams, + oauth_signature: signature, + }; + const parts = Object.keys(headerParams) + .sort() + .map((k) => `${rfc3986(k)}="${rfc3986(headerParams[k])}"`); + if (p.realm) parts.unshift(`realm="${rfc3986(p.realm)}"`); + + return `OAuth ${parts.join(', ')}`; +} diff --git a/packages/backend/src/connectors/engines/rest.engine.spec.ts b/packages/backend/src/connectors/engines/rest.engine.spec.ts index a800d44..85495c6 100644 --- a/packages/backend/src/connectors/engines/rest.engine.spec.ts +++ b/packages/backend/src/connectors/engines/rest.engine.spec.ts @@ -94,6 +94,41 @@ describe('RestEngine', () => { expect(sent.params).not.toHaveProperty('__rawquery'); }); + it('signs OAUTH1 requests with an Authorization: OAuth header over the query params', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + + await engine.execute( + { + baseUrl: 'https://rest.immobilienscout24.de/restapi/api', + authType: 'OAUTH1', + authConfig: { consumerKey: 'CK', consumerSecret: 'CS' }, + }, + { + method: 'GET', + path: '/search/v1.0/search/region', + queryParams: { geocodes: '$geocode', realestatetype: '$type' }, + }, + { geocode: '1276003001', type: 'apartmentrent' }, + ); + + const sent = mockedAxios.mock.calls[0][0] as unknown as { + headers: Record; + params: Record; + }; + const auth = sent.headers.Authorization; + expect(auth).toMatch(/^OAuth /); + expect(auth).toContain('oauth_consumer_key="CK"'); + expect(auth).toContain('oauth_signature_method="HMAC-SHA1"'); + expect(auth).toContain('oauth_signature='); + // Two-legged: no user token in the header. + expect(auth).not.toContain('oauth_token='); + // Query params still go on the wire alongside the signature. + expect(sent.params).toEqual({ + geocodes: '1276003001', + realestatetype: 'apartmentrent', + }); + }); + it('should inject API key auth', async () => { mockedAxios.mockResolvedValue({ data: {} }); diff --git a/packages/backend/src/connectors/engines/rest.engine.ts b/packages/backend/src/connectors/engines/rest.engine.ts index e0e37c0..59a39c9 100644 --- a/packages/backend/src/connectors/engines/rest.engine.ts +++ b/packages/backend/src/connectors/engines/rest.engine.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import axios, { AxiosRequestConfig, AxiosError, Method } from 'axios'; import FormData from 'form-data'; import { createUnblockerProxyAgent } from './unblocker-proxy-agent'; +import { buildOAuth1Header } from './oauth1-signer'; import { OAuth2TokenService } from './oauth2-token.service'; import { LoginTokenService, @@ -163,6 +164,12 @@ export class RestEngine { } } + // OAuth 1.0a signing must happen here — AFTER query params and the body are + // built, because the signature base string folds in the request's query and + // form-urlencoded body params (unlike Bearer/API-key auth, which is set in + // injectAuth before those exist). + this.applyOAuth1Signature(axiosConfig, config); + // Route through the proxy / web-unblocker when the caller asked for it. // The unblocker agent disables upstream TLS verification (Zyte and friends // MITM the connection) — see createUnblockerProxyAgent. Equivalent to @@ -312,9 +319,65 @@ export class RestEngine { ); break; } + case 'OAUTH1': + // Deferred: OAuth 1.0a signs over the query/body params, which aren't + // built yet. Handled by applyOAuth1Signature() after they are. + break; } } + /** + * Apply an OAuth 1.0a (HMAC-SHA1) `Authorization` header. Called after query + * params and the body have been built, since the signature covers them. + * + * authConfig fields: + * - `consumerKey`, `consumerSecret` — required (two-legged/app-only). + * - `token`, `tokenSecret` — optional (three-legged/user context). + * - `realm` — optional Authorization-header realm (not signed). + */ + private applyOAuth1Signature( + axiosConfig: AxiosRequestConfig, + config: { authType: string; authConfig?: Record }, + ): void { + if (config.authType !== 'OAUTH1' || !config.authConfig) return; + const ac = config.authConfig; + + // Only fold a form-urlencoded body into the signature; JSON bodies are not + // part of the OAuth 1.0a base string. + let bodyParams: Record | undefined; + const contentType = String( + (axiosConfig.headers as Record | undefined)?.[ + 'Content-Type' + ] ?? '', + ); + if ( + contentType.includes('application/x-www-form-urlencoded') && + typeof axiosConfig.data === 'string' + ) { + bodyParams = {}; + for (const [k, v] of new URLSearchParams(axiosConfig.data)) { + bodyParams[k] = v; + } + } + + const header = buildOAuth1Header({ + method: String(axiosConfig.method || 'GET'), + url: String(axiosConfig.url), + consumerKey: String(ac.consumerKey), + consumerSecret: String(ac.consumerSecret), + token: ac.token ? String(ac.token) : undefined, + tokenSecret: ac.tokenSecret ? String(ac.tokenSecret) : undefined, + realm: ac.realm ? String(ac.realm) : undefined, + queryParams: axiosConfig.params as Record | undefined, + bodyParams, + }); + + axiosConfig.headers = { + ...axiosConfig.headers, + Authorization: header, + }; + } + private mapParams( mapping: Record, params: Record, From 5603fb4db7627b7d3ed0f962bb7adfcfb5c1ab48 Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 13 Jun 2026 17:14:13 +0200 Subject: [PATCH 2/3] chore(adapters): allow OAUTH1 in adapter catalog validator --- scripts/validate-adapters.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/validate-adapters.mjs b/scripts/validate-adapters.mjs index fcef2ef..7daa1f0 100644 --- a/scripts/validate-adapters.mjs +++ b/scripts/validate-adapters.mjs @@ -53,6 +53,7 @@ const ALLOWED_AUTH_TYPES = new Set([ 'BASIC', 'BASIC_AUTH', 'OAUTH2', + 'OAUTH1', // OAuth 1.0a HMAC-SHA1 request signing (e.g. ImmobilienScout24) 'LOGIN_TOKEN', 'QUERY_AUTH', // existing adapters (destatis, here-geocoding, oxomi) pass the API key as a query string parameter ]); From 47603a6b3a0ca96402809433243435b5c2cffe30 Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 13 Jun 2026 17:17:06 +0200 Subject: [PATCH 3/3] test(adapters): allow OAUTH1 in catalog authType validation --- packages/backend/src/adapters/catalog.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend/src/adapters/catalog.spec.ts b/packages/backend/src/adapters/catalog.spec.ts index 95e01da..48c9ed3 100644 --- a/packages/backend/src/adapters/catalog.spec.ts +++ b/packages/backend/src/adapters/catalog.spec.ts @@ -6,6 +6,7 @@ const VALID_AUTH_TYPES = new Set([ 'BEARER_TOKEN', 'BASIC_AUTH', 'OAUTH2', + 'OAUTH1', 'QUERY_AUTH', 'LOGIN_TOKEN', ]);