From f1c6f9e52305dd467fd5465f191a77f4dcb2ca1b Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:28:27 +0200 Subject: [PATCH] fix(skoda): correct VW authorize params and code extraction The native login was built from synthetic fixtures and failed live with HTTP 401 invalid_token on /oidc/v1/authorize. Verified against the real Skoda API and the python-myskoda reference: - response_type must be 'code' (not 'code id_token') - code_challenge_method must be lowercase 's256' - prompt=login is required - with response_type=code the auth code returns as a QUERY param, not the #fragment, so parseFragment now reads query first, fragment as fallback Confirmed end-to-end against the live API: full flow now yields an access token. --- src/services/skoda/skodaAuth.js | 17 +++++++++++++---- tests/skoda_auth.test.js | 16 ++++++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/services/skoda/skodaAuth.js b/src/services/skoda/skodaAuth.js index e2c9ee1c..9e3857a7 100644 --- a/src/services/skoda/skodaAuth.js +++ b/src/services/skoda/skodaAuth.js @@ -35,8 +35,16 @@ function parseIdk(html) { } function parseFragment(location) { - const hash = location.split('#')[1] || ''; - return Object.fromEntries(new URLSearchParams(hash)); + // response_type=code returns the auth code as a QUERY param on the + // myskoda:// redirect; older hybrid flows used the #fragment. Read both, + // query first (the real Skoda behaviour), fragment as fallback. + const params = {}; + const afterScheme = location.split('://')[1] || location; + const query = (afterScheme.split('?')[1] || '').split('#')[0]; + const fragment = location.split('#')[1] || ''; + for (const [k, v] of new URLSearchParams(fragment)) params[k] = v; + for (const [k, v] of new URLSearchParams(query)) params[k] = v; + return params; } function formBody(fields) { return new URLSearchParams(fields).toString(); } @@ -62,10 +70,11 @@ async function login(email, password, { fetchImpl = fetch } = {}) { client_id: CLIENT_ID, nonce, redirect_uri: REDIRECT_URI, - response_type: 'code id_token', + response_type: 'code', scope: SCOPES, code_challenge: challenge, - code_challenge_method: 'S256', + code_challenge_method: 's256', + prompt: 'login', }).toString(); const start = await followRedirects(jar, authorizeUrl, { method: 'GET', headers: { accept: 'text/html' } }, { fetchImpl }); diff --git a/tests/skoda_auth.test.js b/tests/skoda_auth.test.js index eb51b64a..ca4b07e8 100644 --- a/tests/skoda_auth.test.js +++ b/tests/skoda_auth.test.js @@ -24,12 +24,18 @@ test('parseIdk throws SKODA_AUTH_FLOW_CHANGED on unexpected html', () => { assert.throws(() => auth.parseIdk('maintenance'), (e) => e.code === 'SKODA_AUTH_FLOW_CHANGED'); }); -test('parseFragment reads code from myskoda redirect', () => { +test('parseFragment reads code from myskoda redirect (fragment)', () => { const params = auth.parseFragment('myskoda://redirect/login/#code=THECODE&token_type=bearer&id_token=IDT'); assert.equal(params.code, 'THECODE'); assert.equal(params.id_token, 'IDT'); }); +test('parseFragment reads code from query (response_type=code)', () => { + // Real Skoda behaviour: response_type=code returns the code as a query param. + const params = auth.parseFragment('myskoda://redirect/login/?state=x&code=QCODE'); + assert.equal(params.code, 'QCODE'); +}); + function htmlRes(body) { return { status: 200, headers: new Headers({ 'content-type': 'text/html' }), text: async () => body, json: async () => ({}) }; } @@ -49,7 +55,8 @@ test('login walks the full flow and exchanges the code', async () => { if (url.startsWith(auth.IDENT_BASE + '/oidc/v1/authorize')) return htmlRes(emailPage); if (url.includes('/login/identifier')) return htmlRes(passwordPage); if (url.includes('/login/authenticate')) return redirectRes(auth.IDENT_BASE + '/oidc/v1/oauth/sso?x=1', ['SESSION=s1']); - if (url.includes('/oidc/v1/oauth/sso')) return redirectRes('myskoda://redirect/login/#code=THECODE&id_token=IDT'); + // response_type=code → code comes back as a QUERY param on the redirect + if (url.includes('/oidc/v1/oauth/sso')) return redirectRes('myskoda://redirect/login/?code=THECODE'); if (url.startsWith(auth.API_BASE + '/api/v1/authentication/exchange-authorization-code')) { return jsonRes({ accessToken: 'AT', refreshToken: 'RT', idToken: 'IDT' }); } @@ -57,6 +64,11 @@ test('login walks the full flow and exchanges the code', async () => { }; const tokens = await auth.login('a@b.c', 'pw', { fetchImpl }); assert.deepEqual(tokens, { accessToken: 'AT', refreshToken: 'RT', idToken: 'IDT' }); + // authorize request must use the real Skoda params + const authorize = seen.find((s) => s.url.includes('/oidc/v1/authorize')); + assert.match(authorize.url, /response_type=code(&|$)/); + assert.match(authorize.url, /code_challenge_method=s256/); + assert.match(authorize.url, /prompt=login/); const exchange = seen.find((s) => s.url.includes('exchange-authorization-code')); const body = JSON.parse(exchange.body); assert.equal(body.code, 'THECODE');