@@ -20,6 +20,7 @@ interface ControlToken {
2020
2121interface 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
0 commit comments