Skip to content
Draft
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
36 changes: 23 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 29 additions & 8 deletions packages/ring-client-api/rest-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,21 +355,42 @@ export class RingRestClient {
refresh_token: this.refreshToken,
}
} catch (requestError: any) {
if (grantData.refresh_token) {
// failed request with refresh token
this.refreshToken = undefined
this.authConfig = undefined
logError(requestError)
return this.getAuth()
}

const response = requestError.response || {},
responseData: Auth2faResponse = response.body || {},
responseError =
'error' in responseData && typeof responseData.error === 'string'
? responseData.error
: ''

// If we were using a refresh token and got an invalid_grant error,
// clear the token and retry with email/password (if available)
if (grantData.refresh_token) {
// Check if this is a permanent authentication failure (invalid token)
// Only clear the refresh token if it's actually invalid
// Common error codes that indicate the token is truly invalid:
// - invalid_grant: token is expired or revoked
// - access_denied: token doesn't have access
const isInvalidToken =
response.status === 401 &&
(responseError === 'invalid_grant' ||
responseError === 'access_denied')

if (isInvalidToken) {
// Token is truly invalid, clear it and retry with email/password if available
this.refreshToken = undefined
this.authConfig = undefined
logError('Refresh token is invalid and was cleared')
logError(requestError)
return this.getAuth(twoFactorAuthCode)
}

// For all other errors (network errors, server errors, rate limiting, etc.),
// do NOT clear the refresh token - just fall through to error handling
// This allows the system to recover from temporary failures
logError('Authentication failed but refresh token preserved')
logError(requestError)
}

if (
response.status === 412 || // need 2fa code
(response.status === 400 &&
Expand Down
91 changes: 91 additions & 0 deletions packages/ring-client-api/test/rest-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ afterAll(() => {
afterEach(() => {
client.clearTimeouts()
clearTimeouts()
server.resetHandlers()
})

describe('getAuth', () => {
Expand Down Expand Up @@ -306,6 +307,96 @@ describe('getAuth', () => {
newRefreshToken: await wrapRefreshToken(secondRefreshToken),
})
})

it('should clear refresh token only on invalid_grant error', async () => {
const invalidToken = 'invalid_token'
client = new RingRestClient({
refreshToken: invalidToken,
})

// The invalid token should be cleared when it gets a 401 invalid_grant response
// Because there's no email/password, it will fall back to throwing the error from getGrantData
await expect(() => client.getAuth()).rejects.toThrow(
'Refresh token is not valid. Unable to authenticate with Ring servers',
)

// Verify the token was cleared
expect(client.refreshToken).toBeUndefined()
})

it('should NOT clear refresh token on server errors', async () => {
const validToken = await wrapRefreshToken(refreshToken)
client = new RingRestClient({
refreshToken: validToken,
})

// Mock a server error
server.use(
http.post('https://oauth.ring.com/oauth/token', () => {
// Simulate 503 Service Unavailable
return HttpResponse.json(
{ error: 'service_unavailable' },
{ status: 503 },
)
}),
)

// Should throw an error but NOT clear the token
await expect(() => client.getAuth()).rejects.toThrow(
'Failed to fetch oauth token from Ring',
)

// Verify the token was NOT cleared
expect(client.refreshToken).toBe(validToken)
})

it('should NOT clear refresh token on rate limiting errors', async () => {
const validToken = await wrapRefreshToken(refreshToken)
client = new RingRestClient({
refreshToken: validToken,
})

// Mock a rate limiting error
server.use(
http.post('https://oauth.ring.com/oauth/token', () => {
// Simulate 429 Too Many Requests
return HttpResponse.json(
{ error: 'rate_limit_exceeded' },
{ status: 429 },
)
}),
)

// Should throw an error but NOT clear the token
await expect(() => client.getAuth()).rejects.toThrow(
'Failed to fetch oauth token from Ring',
)

// Verify the token was NOT cleared - this is critical for recovery from temporary issues
expect(client.refreshToken).toBe(validToken)
})

it('should NOT clear refresh token on access_denied from non-401 status', async () => {
const validToken = await wrapRefreshToken(refreshToken)
client = new RingRestClient({
refreshToken: validToken,
})

// Mock an error that has access_denied but not 401 status
server.use(
http.post('https://oauth.ring.com/oauth/token', () => {
return HttpResponse.json({ error: 'access_denied' }, { status: 403 })
}),
)

// Should throw an error but NOT clear the token (only 401 + invalid_grant/access_denied should clear)
await expect(() => client.getAuth()).rejects.toThrow(
'Failed to fetch oauth token from Ring',
)

// Verify the token was NOT cleared
expect(client.refreshToken).toBe(validToken)
})
})

describe('fetch', () => {
Expand Down