Skip to content

Commit b71aab9

Browse files
authored
Feature/eu kia login webview (#52)
* init new webview code and new Kia EU login flow that uses webview to extract code * move to new app login and refresh flow for Kia Europe * do tokenExchange for Access Token for main API * US Hyundai multi-car selection fix * Move new Kia Europe API host to config + config confirmation of what will occur
1 parent cd0bf35 commit b71aab9

File tree

5 files changed

+186
-74
lines changed

5 files changed

+186
-74
lines changed

src/config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,17 @@ export async function loadConfigScreen(bl: Bluelink | undefined = undefined) {
291291
includeCancel: false,
292292
})
293293
}
294+
if (
295+
state.region === 'europe' &&
296+
state.manufacturer === 'Kia' &&
297+
(previousState.region !== 'europe' || previousState.manufacturer !== 'Kia')
298+
) {
299+
confirm('Kia in Europe requires login through a webview. Login window will open automatically.', {
300+
confirmButtonTitle: 'I understand',
301+
includeCancel: false,
302+
})
303+
}
304+
294305
return state
295306
},
296307
isFormValid: ({ username, password, region, pin, tempType, climateTempCold, climateTempWarm }) => {

src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,14 @@ import { confirm, quickOptions } from './lib/scriptable-utils'
141141
Script.complete()
142142
} else {
143143
try {
144+
// check if we need to restart script - needed to clear out any login webviews
145+
if (bl.needRestart()) {
146+
logger.log('Restarting script to clear webview')
147+
const scriptUrl = URLScheme.forRunningScript()
148+
Safari.open(scriptUrl)
149+
return
150+
}
151+
144152
const resp = await createApp(blConfig, bl)
145153
// @ts-ignore - undocumented api
146154
App.close() // add this back after dev

src/lib/bluelink-regions/base.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface BluelinkTokens {
1515
expiry: number
1616
authCookie?: string
1717
authId?: string
18+
additionalTokens?: Record<string, string>
1819
}
1920

2021
export interface CarOption {
@@ -153,6 +154,7 @@ export class Bluelink {
153154
protected debugLastRequest: DebugLastRequest | undefined
154155
protected logger: any
155156
protected loginFailure: boolean
157+
protected loginRequiredWebview: boolean
156158
protected carOptions: CarOption[]
157159
protected distanceUnit: string
158160
protected lastCommandSent: number | undefined
@@ -167,6 +169,7 @@ export class Bluelink {
167169
this.authHeader = 'Authentication'
168170
this.tokens = undefined
169171
this.loginFailure = false
172+
this.loginRequiredWebview = false
170173
this.carOptions = []
171174
this.debugLastRequest = undefined
172175
this.tempLookup = undefined
@@ -281,6 +284,10 @@ export class Bluelink {
281284
return this.loginFailure
282285
}
283286

287+
public needRestart(): boolean {
288+
return this.loginRequiredWebview
289+
}
290+
284291
public getCachedStatus(): Status {
285292
return {
286293
car: this.cache.car,

src/lib/bluelink-regions/europe.ts

Lines changed: 156 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface ControlToken {
2020

2121
interface APIConfig {
2222
apiDomain: string
23+
newApiDomain?: string
2324
apiPort: number
2425
appId: string
2526
authCfb: string
@@ -47,6 +48,7 @@ const API_CONFIG: Record<string, APIConfig> = {
4748
},
4849
kia: {
4950
apiDomain: 'prd.eu-ccapi.kia.com',
51+
newApiDomain: 'cci-api-eu.kia.com',
5052
apiPort: 8080,
5153
appId: 'a2b8469b-30a3-4361-8e13-6fceea8fbe74',
5254
authCfb: 'wLTVxwidmH8CfJYBWSnHD6E0huk0ozdiuygB4hLkM5XCgzAL1Dk5sE36d/bx5PFMbZs=',
@@ -274,109 +276,193 @@ export class BluelinkEurope extends Bluelink {
274276
}
275277
}
276278

279+
protected loginWithWebview(start_url: string, callback_url: string) {
280+
// @ts-ignore
281+
return new Promise((resolve, reject) => {
282+
const webview = new WebView()
283+
webview.shouldAllowRequest = (request) => {
284+
if (!request.url.startsWith(callback_url)) return true
285+
// we have been redirected to the callback URL - return URL and update webview to a success page
286+
resolve(request.url)
287+
webview.loadHTML(
288+
`
289+
<!DOCTYPE html>
290+
<html>
291+
<body style="background-color:#1c1c1e;">
292+
293+
<center>
294+
<h1 style="color: white; font-family: Arial, Helvetica; font-size: xxx-large;">Login Successful</h1>
295+
<p style="color: white; font-family: Arial, Helvetica; font-size: xx-large;">This screen should auto-close, if not please close window.</p>
296+
</center>
297+
298+
</body>
299+
</html>
300+
`,
301+
)
302+
return false
303+
}
304+
webview.loadURL(start_url)
305+
webview
306+
.present(false)
307+
.then(() => {
308+
reject(new Error('Could not complete login. Please try again.'))
309+
})
310+
.catch(reject)
311+
})
312+
}
313+
277314
protected async KiaLogin(): Promise<BluelinkTokens | undefined> {
278-
// start login - new flow not involving forms anymore
279-
const respLoginStart = await this.request({
280-
url: `https://${this.apiConfig.authHost}/auth/api/v2/user/oauth2/authorize?client_id=${this.apiConfig.clientId}&response_type=code&&redirect_uri=${this.apiDomain}/api/v1/user/oauth2/redirect&lang=${this.lang}&state=ccsp`,
315+
const authUrl =
316+
`https://${this.apiConfig.authHost}/auth/api/v2/user/oauth2/authorize?` +
317+
[
318+
`client_id=01b36c86-79e8-486c-8009-15f2ad88d670`,
319+
`redirect_uri=https://oneapp.kia.com/redirect`,
320+
'response_type=code',
321+
'scope=account.token.transfer%20account.id.generate%20account.puid.userinfos%20account.userinfo%20read%20account.userinfos%20puid%20email%20name%20mobileNum%20birthdate%20lang%20country%20signUpDate%20gender%20nationInfo%20certProfile%20offline',
322+
'response_type=code',
323+
'state=hmgoneapp',
324+
'ui_locales=en-GB',
325+
].join('&')
326+
327+
// open webview for user to login - which handles detecting login, settings webview to authURL and finally detecting the redirectURL and returning
328+
const callback_url = (await this.loginWithWebview(authUrl, 'https://oneapp.kia.com/redirect')) as string
329+
330+
// extract code from callback URL
331+
const codeParams = Url.parse(callback_url, true).query
332+
const code = codeParams.code
333+
if (!code) {
334+
const error = `Failed to extract code from redirect ${callback_url}`
335+
if (this.config.debugLogging) this.logger.log(error)
336+
return undefined // likely username or password incorrect
337+
}
338+
339+
// swap code for tokens
340+
const respTokens = await this.request({
341+
url: `https://${this.apiConfig.newApiDomain}/domain/api/v1/auth/token?code=${code}`,
342+
method: 'POST',
281343
noAuth: true,
282-
notJSON: true,
283344
validResponseFunction: this.requestResponseValid,
345+
headers: {
346+
'client-id': 'com.kia.oneapp.eu',
347+
},
284348
})
285349

286-
if (!this.requestResponseValid(respLoginStart.resp, respLoginStart.json).valid) {
287-
const error = `Failed to perform first login action ${JSON.stringify(respLoginStart.resp)}`
350+
if (!this.requestResponseValid(respTokens.resp, respTokens.json).valid) {
351+
const error = `Failed to login ${JSON.stringify(respTokens.resp)}`
288352
if (this.config.debugLogging) this.logger.log(error)
289353
throw Error(error)
290354
}
291355

292-
// extract connector_session_key from URL
293-
const connectorURL = Url.parse(decodeURI(respLoginStart.resp.url), true).query
294-
const nextUri = connectorURL.next_uri
295-
if (!nextUri || typeof nextUri !== 'string') {
296-
const error = `Failed to extract next_uri from login response ${JSON.stringify(respLoginStart.resp)}`
297-
if (this.config.debugLogging) this.logger.log(error)
298-
throw Error(error)
299-
}
300-
const connectorParams = Url.parse(decodeURI(nextUri), true).query
301-
const connectorSessionKey = connectorParams.connector_session_key
302-
if (!connectorSessionKey) {
303-
const error = `Failed to extract connector_session_key ${JSON.stringify(respLoginStart.resp)}`
304-
if (this.config.debugLogging) this.logger.log(error)
305-
throw Error(error)
356+
// this causes the script to restart to dismiss the webview if this happens on first load
357+
this.loginRequiredWebview = true
358+
359+
return this.tokenExchange({
360+
accessToken: '', // set in tokenExchange
361+
refreshToken: '', // there is no single refresh token - we use additionalTokens for this
362+
expiry: Math.floor(Date.now() / 1000) + Number(respTokens.json.expiresIn), // we only get a expireIn not a actual date
363+
authId: await this.getDeviceId(),
364+
additionalTokens: {
365+
access: respTokens.json.accessToken,
366+
refresh: respTokens.json.refreshToken,
367+
exchangeableAccess: respTokens.json.exchangeableAccessToken,
368+
exchangeableRefresh: respTokens.json.exchangeableRefreshToken,
369+
nonCcsToken: respTokens.json.nonCcsToken,
370+
nonCcsRefreshToken: respTokens.json.nonCcsRefreshToken,
371+
idToken: respTokens.json.idToken,
372+
},
373+
})
374+
}
375+
376+
protected async tokenExchange(tokens: BluelinkTokens): Promise<BluelinkTokens | undefined> {
377+
if (!tokens || !tokens.additionalTokens || !isNotEmptyObject(tokens.additionalTokens)) {
378+
if (this.config.debugLogging) this.logger.log('Cannot exchange tokens - no additional tokens')
379+
return undefined
306380
}
307381

308-
// login
309-
const respLogin = await this.request({
310-
url: `https://${this.apiConfig.authHost}/auth/account/signin`,
382+
if (this.config.debugLogging) this.logger.log('Exchanging tokens using new method')
383+
384+
const respToken = await this.request({
385+
url: `https://${this.apiConfig.newApiDomain}/domain/api/v1/auth/token-exchange?serviceType=CCS`,
386+
method: 'POST',
311387
noAuth: true,
312-
notJSON: true,
313388
validResponseFunction: this.requestResponseValid,
314-
noRedirect: true,
315-
data: [
316-
`client_id=${this.apiConfig.clientId}`,
317-
'encryptedPassword=false',
318-
'orgHmgSid=',
319-
`username=${encodeURIComponent(this.config.auth.username)}`,
320-
`password=${encodeURIComponent(this.config.auth.password)}`,
321-
`redirect_uri=https://${this.apiConfig.apiDomain}:${this.apiConfig.apiPort}/api/v1/user/oauth2/redirect`,
322-
'state=ccsp',
323-
'remember_me=false',
324-
`connector_session_key=${connectorSessionKey}`,
325-
'_csrf=',
326-
].join('&'),
327389
headers: {
328-
'Content-Type': 'application/x-www-form-urlencoded',
329-
Origin: this.apiConfig.authHost,
390+
'client-id': 'com.kia.oneapp.eu',
391+
Authentication: tokens.additionalTokens['idToken'] || '',
392+
Authorization: `Bearer ${tokens.additionalTokens['access'] || ''}`,
393+
'exchangeable-token': tokens.additionalTokens['exchangeableAccess'] || '',
394+
'non-ccs-token': tokens.additionalTokens['nonCcsToken'] || '',
330395
},
331396
})
332397

333-
if (!this.requestResponseValid(respLogin.resp, respLogin.json).valid) {
334-
const error = `Failed to perform login ${JSON.stringify(respLogin.resp)}`
335-
if (this.config.debugLogging) this.logger.log(error)
336-
return undefined // likely username or password incorrect
398+
if (this.requestResponseValid(respToken.resp, respToken.json).valid) {
399+
tokens.accessToken = `Bearer ${respToken.json.accessToken}`
400+
tokens.expiry = Math.floor(Date.now() / 1000) + Number(respToken.json.expiresTime) // we only get a expireIn not a actual date
401+
return tokens
337402
}
338403

339-
// extract code from Redirect Location Header
340-
const codeParams = Url.parse(decodeURI(respLogin.resp.headers.Location), true).query
341-
const code = codeParams.code
342-
if (!code) {
343-
const error = `Failed to extract code ${JSON.stringify(respLogin.resp)}`
344-
if (this.config.debugLogging) this.logger.log(error)
345-
return undefined // likely username or password incorrect
404+
const error = `Token Exchange Failed: ${JSON.stringify(respToken.json)} request ${JSON.stringify(this.debugLastRequest)}`
405+
if (this.config.debugLogging) this.logger.log(error)
406+
return undefined
407+
}
408+
409+
protected async newRefreshTokens(): Promise<BluelinkTokens | undefined> {
410+
if (!this.cache || !this.cache.token.additionalTokens || !isNotEmptyObject(this.cache.token.additionalTokens)) {
411+
if (this.config.debugLogging) this.logger.log('No additional tokens - cannot refresh')
412+
return undefined
346413
}
347414

348-
// final login to get tokens
415+
if (this.config.debugLogging) this.logger.log('Refreshing tokens using new method')
416+
349417
const respTokens = await this.request({
350-
url: `https://${this.apiConfig.authHost}/auth/api/v2/user/oauth2/token`,
418+
url: 'https://cci-api-eu.kia.com/domain/api/v2/auth/token-refresh',
419+
data: JSON.stringify({
420+
accessToken: this.cache.token.additionalTokens['access'],
421+
refreshToken: this.cache.token.additionalTokens['refresh'],
422+
exchangeableAccessToken: this.cache.token.additionalTokens['exchangeableAccess'],
423+
exchangeableRefreshToken: this.cache.token.additionalTokens['exchangeableRefresh'],
424+
nonCcsToken: this.cache.token.additionalTokens['nonCcsToken'],
425+
nonCcsRefreshToken: this.cache.token.additionalTokens['nonCcsRefreshToken'],
426+
}),
351427
noAuth: true,
352428
validResponseFunction: this.requestResponseValid,
353-
data: [
354-
'grant_type=authorization_code',
355-
`code=${code}`,
356-
`redirect_uri=https://${this.apiConfig.apiDomain}:${this.apiConfig.apiPort}/api/v1/user/oauth2/redirect`,
357-
`client_id=${this.apiConfig.clientId}`,
358-
'client_secret=secret',
359-
].join('&'),
360429
headers: {
361-
'Content-Type': 'application/x-www-form-urlencoded',
430+
'client-id': 'com.kia.oneapp.eu',
431+
Authentication: this.cache.token.additionalTokens['idToken'] || '',
432+
Authorization: `Bearer ${this.cache.token.additionalTokens['access'] || ''}`,
433+
'exchangeable-token': this.cache.token.additionalTokens['exchangeableAccess'] || '',
434+
'non-ccs-token': this.cache.token.additionalTokens['nonCcsToken'] || '',
362435
},
363436
})
364437

365-
if (!this.requestResponseValid(respTokens.resp, respTokens.json).valid) {
366-
const error = `Failed to login ${JSON.stringify(respTokens.resp)}`
367-
if (this.config.debugLogging) this.logger.log(error)
368-
throw Error(error)
438+
if (this.requestResponseValid(respTokens.resp, respTokens.json).valid) {
439+
return this.tokenExchange({
440+
accessToken: '', // set in tokenExchange
441+
refreshToken: '', // there is no single refresh token - we use additionalTokens for this
442+
expiry: Math.floor(Date.now() / 1000) + Number(respTokens.json.expiresIn), // we only get a expireIn not a actual date
443+
authId: await this.getDeviceId(),
444+
additionalTokens: {
445+
access: respTokens.json.accessToken,
446+
refresh: respTokens.json.refreshToken,
447+
exchangeableAccess: respTokens.json.exchangeableAccessToken,
448+
exchangeableRefresh: respTokens.json.exchangeableRefreshToken,
449+
nonCcsToken: respTokens.json.nonCcsToken,
450+
nonCcsRefreshToken: respTokens.json.nonCcsRefreshToken,
451+
idToken: respTokens.json.idToken,
452+
},
453+
})
369454
}
370455

371-
return {
372-
accessToken: `Bearer ${respTokens.json.access_token}`,
373-
refreshToken: respTokens.json.refresh_token,
374-
expiry: Math.floor(Date.now() / 1000) + Number(respTokens.json.expires_in), // we only get a expireIn not a actual date
375-
authId: await this.getDeviceId(),
376-
}
456+
const error = `Refresh Failed: ${JSON.stringify(respTokens.json)} request ${JSON.stringify(this.debugLastRequest)}`
457+
if (this.config.debugLogging) this.logger.log(error)
458+
return undefined
377459
}
378460

379461
protected async refreshTokens(): Promise<BluelinkTokens | undefined> {
462+
if (this.cache && this.cache.token.additionalTokens) {
463+
return await this.newRefreshTokens()
464+
}
465+
380466
if (!this.cache.token.refreshToken) {
381467
if (this.config.debugLogging) this.logger.log('No refresh token - cannot refresh')
382468
return undefined

src/lib/bluelink-regions/usa.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,10 @@ export class BluelinkUSA extends Bluelink {
144144
if (this.requestResponseValid(resp.resp, resp.json).valid && resp.json.enrolledVehicleDetails.length > 1 && !vin) {
145145
for (const vehicle of resp.json.enrolledVehicleDetails) {
146146
this.carOptions.push({
147-
vin: vehicle.vin,
148-
nickName: vehicle.nickName,
149-
modelName: vehicle.modelCode,
150-
modelYear: vehicle.modelYear,
147+
vin: vehicle.vehicleDetails.vin,
148+
nickName: vehicle.vehicleDetails.nickName,
149+
modelName: vehicle.vehicleDetails.modelCode,
150+
modelYear: vehicle.vehicleDetails.modelYear,
151151
})
152152
}
153153
return undefined

0 commit comments

Comments
 (0)