Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
1 change: 1 addition & 0 deletions packages/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ enum AuthType {
BEARER_TOKEN
BASIC_AUTH
OAUTH2
OAUTH1
WS_SECURITY
CERTIFICATE
CONNECTION_STRING
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/adapters/catalog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const VALID_AUTH_TYPES = new Set([
'BEARER_TOKEN',
'BASIC_AUTH',
'OAUTH2',
'OAUTH1',
'QUERY_AUTH',
'LOGIN_TOKEN',
]);
Expand Down
13 changes: 6 additions & 7 deletions packages/backend/src/adapters/de/immobilienscout24.json
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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": [
Expand Down
82 changes: 82 additions & 0 deletions packages/backend/src/connectors/engines/oauth1-signer.spec.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
});
120 changes: 120 additions & 0 deletions packages/backend/src/connectors/engines/oauth1-signer.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
/** form-urlencoded body params only (NOT json bodies). */
bodyParams?: Record<string, unknown>;
/** 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<string, string> = {
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<string, unknown>) => {
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<string, string> = {
...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(', ')}`;
}
35 changes: 35 additions & 0 deletions packages/backend/src/connectors/engines/rest.engine.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
params: Record<string, unknown>;
};
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: {} });

Expand Down
63 changes: 63 additions & 0 deletions packages/backend/src/connectors/engines/rest.engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
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,
Expand Down Expand Up @@ -163,6 +164,12 @@
}
}

// 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
Expand Down Expand Up @@ -312,9 +319,65 @@
);
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<string, unknown> },
): 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<string, unknown> | undefined;
const contentType = String(
(axiosConfig.headers as Record<string, unknown> | 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;

Check failure

Code scanning / CodeQL

Remote property injection High

A property name to write to depends on a
user-provided value
.
}
}

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<string, unknown> | undefined,
bodyParams,
});

axiosConfig.headers = {
...axiosConfig.headers,
Authorization: header,
};
}

private mapParams(
mapping: Record<string, unknown>,
params: Record<string, unknown>,
Expand Down
1 change: 1 addition & 0 deletions scripts/validate-adapters.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
]);
Expand Down
Loading