Skip to content

Commit 4ea16ed

Browse files
committed
chore: merge master
2 parents 29cdd74 + 8809a53 commit 4ea16ed

6 files changed

Lines changed: 324 additions & 46 deletions

File tree

Authorization.ts

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import Axios, { AxiosResponse } from 'axios'
1+
import Axios, { AxiosResponse, AxiosError } from 'axios'
2+
import * as log from 'log'
23

34
export interface AccessTokenResponse {
45
access_token: string
@@ -54,21 +55,58 @@ export async function fetchAccessAndRefreshToken(
5455
export async function fetchFreshAccessToken(
5556
refreshToken: string
5657
): Promise<AccessTokenResponse> {
57-
const response: AxiosResponse = await Axios.post(
58-
'https://api.amazon.com/auth/o2/token',
59-
`grant_type=refresh_token&refresh_token=${refreshToken}&client_id=${process.env.ALEXA_CLIENT_ID}&client_secret=${process.env.ALEXA_CLIENT_SECRET}`,
60-
{
61-
headers: {
62-
'Content-Type': 'application/x-www-form-urlencoded',
63-
},
64-
}
65-
)
58+
const refreshTokenPrefix = refreshToken?.substring(0, 12) + '...'
59+
const clientId = process.env.ALEXA_CLIENT_ID?.substring(0, 20) + '...'
60+
61+
try {
62+
log.info('Attempting token refresh', {
63+
refreshTokenPrefix,
64+
clientId,
65+
endpoint: 'https://api.amazon.com/auth/o2/token'
66+
})
6667

67-
// {
68-
// "access_token":"Atza|IQEBLjAsAhRmHjNmHpi0U-Dme37rR6CuUpSR...",
69-
// "token_type":"bearer",
70-
// "expires_in":3600,
71-
// "refresh_token":"Atzr|IQEBLzAtAhRxpMJxdwVz2Nn6f2y-tpJX3DeX..."
72-
// }
73-
return response.data
68+
const response: AxiosResponse = await Axios.post(
69+
'https://api.amazon.com/auth/o2/token',
70+
`grant_type=refresh_token&refresh_token=${refreshToken}&client_id=${process.env.ALEXA_CLIENT_ID}&client_secret=${process.env.ALEXA_CLIENT_SECRET}`,
71+
{
72+
headers: {
73+
'Content-Type': 'application/x-www-form-urlencoded',
74+
},
75+
}
76+
)
77+
78+
log.info('Token refresh successful', {
79+
refreshTokenPrefix,
80+
hasNewAccessToken: !!response.data.access_token,
81+
hasNewRefreshToken: !!response.data.refresh_token,
82+
expiresIn: response.data.expires_in
83+
})
84+
85+
return response.data
86+
} catch (error) {
87+
const axiosError = error as AxiosError
88+
89+
log.error('Token refresh failed', {
90+
refreshTokenPrefix,
91+
clientId,
92+
status: axiosError.response?.status,
93+
statusText: axiosError.response?.statusText,
94+
errorCode: axiosError.code,
95+
errorMessage: axiosError.message,
96+
responseData: axiosError.response?.data,
97+
headers: axiosError.response?.headers,
98+
url: axiosError.config?.url,
99+
method: axiosError.config?.method
100+
})
101+
102+
// Re-throw with enhanced error context
103+
const enhancedError = new Error(`Token refresh failed: ${axiosError.response?.status} ${axiosError.response?.statusText}`)
104+
enhancedError.name = 'TokenRefreshError'
105+
;(enhancedError as any).originalError = error
106+
;(enhancedError as any).refreshTokenPrefix = refreshTokenPrefix
107+
;(enhancedError as any).status = axiosError.response?.status
108+
;(enhancedError as any).responseData = axiosError.response?.data
109+
110+
throw enhancedError
111+
}
74112
}

db.ts

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as AWS from 'aws-sdk'
22
import dayjs = require('dayjs')
3+
import * as log from 'log'
34
import {
45
fetchFreshAccessToken,
56
PartialUserRecord,
@@ -168,18 +169,61 @@ export async function getUserRecord(
168169
const tokenExpiry = dayjs(data.accessTokenExpiry).subtract(15, 'second')
169170

170171
if (tokenExpiry.isBefore(now)) {
171-
const newTokens = await fetchFreshAccessToken(data.refreshToken)
172-
173-
await upsertTokens(
174-
{
175-
userId,
176-
accessToken: newTokens.access_token,
177-
refreshToken: newTokens.refresh_token,
178-
},
179-
newTokens.expires_in
180-
)
181-
182-
data.accessToken = newTokens.access_token
172+
log.info('Token expired, attempting refresh', {
173+
userId: userId.substring(0, 8) + '...',
174+
tokenExpiry: tokenExpiry.toISOString(),
175+
currentTime: now.toISOString(),
176+
refreshTokenPrefix: data.refreshToken?.substring(0, 12) + '...'
177+
})
178+
179+
try {
180+
const newTokens = await fetchFreshAccessToken(data.refreshToken)
181+
182+
await upsertTokens(
183+
{
184+
userId,
185+
accessToken: newTokens.access_token,
186+
refreshToken: newTokens.refresh_token,
187+
},
188+
newTokens.expires_in
189+
)
190+
191+
data.accessToken = newTokens.access_token
192+
193+
log.info('Token refresh and database update successful', {
194+
userId: userId.substring(0, 8) + '...',
195+
hasNewAccessToken: !!newTokens.access_token,
196+
hasNewRefreshToken: !!newTokens.refresh_token,
197+
newExpiresIn: newTokens.expires_in
198+
})
199+
} catch (error) {
200+
log.error('Token refresh failed in getUserRecord', {
201+
userId: userId.substring(0, 8) + '...',
202+
errorName: error.name,
203+
errorMessage: error.message,
204+
tokenExpiry: tokenExpiry.toISOString(),
205+
refreshTokenPrefix: data.refreshToken?.substring(0, 12) + '...',
206+
originalError: error.originalError?.message,
207+
status: error.status,
208+
responseData: error.responseData
209+
})
210+
211+
// Re-throw with additional context for upstream handlers
212+
const contextualError = new Error(`Token refresh failed for user ${userId.substring(0, 8)}...: ${error.message}`)
213+
contextualError.name = 'UserTokenRefreshError'
214+
;(contextualError as any).userId = userId
215+
;(contextualError as any).originalError = error
216+
;(contextualError as any).tokenExpiry = tokenExpiry.toISOString()
217+
218+
throw contextualError
219+
}
220+
} else {
221+
log.debug('Token still valid, no refresh needed', {
222+
userId: userId.substring(0, 8) + '...',
223+
tokenExpiry: tokenExpiry.toISOString(),
224+
currentTime: now.toISOString(),
225+
timeUntilExpiry: tokenExpiry.diff(now, 'seconds') + ' seconds'
226+
})
183227
}
184228
}
185229

handler.ts

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,16 +74,120 @@ export const skill = async function (event, context) {
7474
const accessToken = extractAccessTokenFromEvent(event)
7575
event.profile = await fetchProfile(accessToken)
7676
} catch (e) {
77+
// Enhanced error logging for token-related failures
78+
const accessToken = extractAccessTokenFromEvent(event)
79+
const tokenPrefix = accessToken?.substring(0, 12) + '...'
80+
81+
// Determine the error type and appropriate response
82+
let errorType = 'EXPIRED_AUTHORIZATION_CREDENTIAL'
83+
let errorMessage = 'invalid token'
84+
let shouldDisableSkill = true
85+
86+
// Handle ProfileFetchError specifically
87+
if (e.name === 'ProfileFetchError') {
88+
if (e.isRetryable && !e.isAuthError) {
89+
// Network/server errors should not disable the skill
90+
errorType = 'INTERNAL_ERROR'
91+
errorMessage = 'temporary service unavailable'
92+
shouldDisableSkill = false
93+
94+
log.warn('NETWORK/SERVER ERROR detected - NOT disabling skill', {
95+
directive: event.directive?.header?.name,
96+
tokenPrefix,
97+
errorCategory: e.errorCategory,
98+
status: e.status,
99+
isRetryable: e.isRetryable,
100+
isAuthError: e.isAuthError,
101+
responseData: e.responseData,
102+
requestId: context?.awsRequestId
103+
})
104+
} else if (e.isAuthError) {
105+
// Actual auth errors should disable the skill
106+
shouldDisableSkill = true
107+
108+
log.error('AUTHENTICATION ERROR detected - skill may be disabled', {
109+
directive: event.directive?.header?.name,
110+
tokenPrefix,
111+
errorCategory: e.errorCategory,
112+
status: e.status,
113+
isAuthError: e.isAuthError,
114+
responseData: e.responseData,
115+
requestId: context?.awsRequestId
116+
})
117+
}
118+
}
119+
120+
log.error('Skill authentication failed', {
121+
directive: event.directive?.header?.name,
122+
tokenPrefix,
123+
errorName: e.name,
124+
errorMessage: e.message,
125+
errorStack: e.stack,
126+
requestId: context?.awsRequestId,
127+
userId: event.profile?.user_id || 'unknown',
128+
errorType,
129+
shouldDisableSkill,
130+
// Enhanced classification for ProfileFetchError
131+
errorCategory: e.errorCategory || 'unknown',
132+
isRetryable: e.isRetryable || false,
133+
isAuthError: e.isAuthError || false,
134+
// Check if this is a token refresh error
135+
isTokenRefreshError: e.name === 'UserTokenRefreshError' || e.name === 'TokenRefreshError',
136+
originalTokenError: e.originalError?.message,
137+
tokenStatus: e.status,
138+
responseData: e.responseData
139+
})
140+
141+
// Log specific patterns that indicate different failure types
142+
if (e.name === 'UserTokenRefreshError' || e.name === 'TokenRefreshError') {
143+
log.error('TOKEN REFRESH FAILURE detected - potential skill auto-disable cause', {
144+
userId: e.userId || 'unknown',
145+
refreshTokenPrefix: e.refreshTokenPrefix,
146+
tokenExpiry: e.tokenExpiry,
147+
httpStatus: e.status,
148+
amazonResponse: e.responseData
149+
})
150+
} else if (e.name === 'ProfileFetchError' && e.errorCategory === 'NETWORK_ERROR') {
151+
log.warn('NETWORK ERROR on profile fetch - should NOT cause skill disable', {
152+
tokenPrefix,
153+
errorCategory: e.errorCategory,
154+
errorCode: e.originalError?.code,
155+
likelyCase: 'Temporary network/DNS/timeout issue'
156+
})
157+
} else if (e.name === 'ProfileFetchError' && e.errorCategory === 'SERVER_ERROR') {
158+
log.warn('AMAZON API SERVER ERROR - should NOT cause skill disable', {
159+
tokenPrefix,
160+
status: e.status,
161+
errorCategory: e.errorCategory,
162+
likelyCase: 'Amazon API temporary downtime/overload'
163+
})
164+
} else if (e.message?.includes('401') || e.message?.includes('403') ||
165+
(e.name === 'ProfileFetchError' && e.isAuthError)) {
166+
log.error('HTTP AUTH ERROR detected', {
167+
tokenPrefix,
168+
httpError: e.message,
169+
errorCategory: e.errorCategory || 'unknown',
170+
likelyCase: 'User revoked permissions or client credentials changed'
171+
})
172+
} else if (e.message?.includes('invalid_grant')) {
173+
log.error('INVALID_GRANT ERROR detected', {
174+
tokenPrefix,
175+
likelyCase: 'Refresh token expired, user permissions revoked, or client config changed'
176+
})
177+
}
178+
77179
const response = createErrorResponse(
78180
event,
79-
'EXPIRED_AUTHORIZATION_CREDENTIAL',
80-
'invalid token'
181+
errorType,
182+
errorMessage
81183
)
184+
82185
log.notice(
83-
'REQUEST: %j \n EXCEPTION: %o \n RESPONSE: %j',
186+
'REQUEST: %j \n EXCEPTION: %o \n RESPONSE: %j \n SKILL_DISABLE_RISK: %s',
84187
event,
85188
e,
86-
response
189+
response,
190+
shouldDisableSkill ? 'HIGH' : 'LOW'
87191
)
88192
return response
89193
}

helper.ts

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,97 @@ export function extractAccessTokenFromEvent(event): string {
6565
}
6666

6767
export async function fetchProfile(accessToken: string) {
68-
const response: AxiosResponse = await Axios.get(
69-
'https://api.amazon.com/user/profile',
70-
{
71-
headers: {
72-
Authorization: `Bearer ${accessToken}`,
73-
},
68+
const tokenPrefix = accessToken?.substring(0, 12) + '...'
69+
70+
try {
71+
log.debug('Fetching user profile from Amazon', {
72+
tokenPrefix,
73+
endpoint: 'https://api.amazon.com/user/profile'
74+
})
75+
76+
const response: AxiosResponse = await Axios.get(
77+
'https://api.amazon.com/user/profile',
78+
{
79+
headers: {
80+
Authorization: `Bearer ${accessToken}`,
81+
},
82+
}
83+
)
84+
85+
log.debug('Profile fetch successful', {
86+
tokenPrefix,
87+
userId: response.data?.user_id?.substring(0, 8) + '...',
88+
email: response.data?.email ? 'present' : 'absent'
89+
})
90+
91+
return response.data
92+
} catch (error) {
93+
const axiosError = error as any
94+
const status = axiosError.response?.status
95+
const statusText = axiosError.response?.statusText
96+
const responseData = axiosError.response?.data
97+
98+
// Categorize the error type
99+
let errorCategory = 'UNKNOWN'
100+
let isRetryable = false
101+
let isAuthError = false
102+
103+
if (status) {
104+
if (status === 401) {
105+
errorCategory = 'INVALID_TOKEN'
106+
isAuthError = true
107+
} else if (status === 403) {
108+
errorCategory = 'FORBIDDEN_TOKEN'
109+
isAuthError = true
110+
} else if (status >= 500 && status <= 599) {
111+
errorCategory = 'SERVER_ERROR'
112+
isRetryable = true
113+
} else if (status === 429) {
114+
errorCategory = 'RATE_LIMITED'
115+
isRetryable = true
116+
} else if (status >= 400 && status <= 499) {
117+
errorCategory = 'CLIENT_ERROR'
118+
isAuthError = true
119+
}
120+
} else {
121+
// Network-level errors (timeouts, DNS, connection refused)
122+
if (axiosError.code === 'ECONNREFUSED' || axiosError.code === 'ENOTFOUND' ||
123+
axiosError.code === 'ETIMEDOUT' || axiosError.code === 'ECONNRESET') {
124+
errorCategory = 'NETWORK_ERROR'
125+
isRetryable = true
126+
} else {
127+
errorCategory = 'CONNECTION_ERROR'
128+
isRetryable = true
129+
}
74130
}
75-
)
76131

77-
return response.data
132+
log.error('Profile fetch failed', {
133+
tokenPrefix,
134+
status,
135+
statusText,
136+
errorCategory,
137+
isRetryable,
138+
isAuthError,
139+
errorCode: axiosError.code,
140+
errorMessage: axiosError.message,
141+
responseData,
142+
url: axiosError.config?.url,
143+
timeout: axiosError.config?.timeout
144+
})
145+
146+
// Create enhanced error with classification
147+
const enhancedError = new Error(`Profile fetch failed: ${status ? `${status} ${statusText}` : axiosError.message}`)
148+
enhancedError.name = 'ProfileFetchError'
149+
;(enhancedError as any).originalError = error
150+
;(enhancedError as any).tokenPrefix = tokenPrefix
151+
;(enhancedError as any).status = status
152+
;(enhancedError as any).errorCategory = errorCategory
153+
;(enhancedError as any).isRetryable = isRetryable
154+
;(enhancedError as any).isAuthError = isAuthError
155+
;(enhancedError as any).responseData = responseData
156+
157+
throw enhancedError
158+
}
78159
}
79160

80161
export function createErrorResponse(

0 commit comments

Comments
 (0)