Improve OAuth error handling in configuration flows (#103157)

* Improve OAuth error handling in configuration flows

* Update strings for all integrations that use oauth2 config flow

* Remove invalid_auth strings

* Revert change to release

* Revert close change in aiohttp mock
This commit is contained in:
Allen Porter 2023-11-11 02:02:51 -08:00 committed by GitHub
parent 667a453a35
commit 787fb3b954
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 395 additions and 124 deletions

View File

@ -17,7 +17,10 @@
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
@ -25,17 +28,9 @@
},
"entity": {
"sensor": {
"hopfreepowerstart": {
"name": "Hour of free power start"
},
"hopfreepowerend": {
"name": "Hour of free power end"
}
"hopfreepowerstart": { "name": "Hour of free power start" },
"hopfreepowerend": { "name": "Hour of free power end" }
},
"select": {
"hopselector": {
"name": "Hour of free power"
}
}
"select": { "hopselector": { "name": "Hour of free power" } }
}
}

View File

@ -22,7 +22,10 @@
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"wrong_account": "The user credentials provided do not match this Fitbit account."
"wrong_account": "The user credentials provided do not match this Fitbit account.",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -16,7 +16,10 @@
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
@ -24,21 +27,11 @@
},
"entity": {
"sensor": {
"find_count": {
"name": "Total finds"
},
"hide_count": {
"name": "Total hides"
},
"favorite_points": {
"name": "Favorite points"
},
"souvenir_count": {
"name": "Total souvenirs"
},
"awarded_favorite_points": {
"name": "Awarded favorite points"
}
"find_count": { "name": "Total finds" },
"hide_count": { "name": "Total hides" },
"favorite_points": { "name": "Favorite points" },
"souvenir_count": { "name": "Total souvenirs" },
"awarded_favorite_points": { "name": "Awarded favorite points" }
}
}
}

View File

@ -22,7 +22,10 @@
"code_expired": "Authentication code expired or credential setup is invalid, please try again.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
"api_disabled": "You must enable the Google Calendar API in the Google Cloud Console"
"api_disabled": "You must enable the Google Calendar API in the Google Cloud Console",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -21,7 +21,10 @@
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
"unknown": "[%key:common::config_flow::error::unknown%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -22,7 +22,10 @@
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"wrong_account": "Wrong account: Please authenticate with {email}."
"wrong_account": "Wrong account: Please authenticate with {email}.",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -4,9 +4,7 @@
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"auth": {
"title": "Link Google Account"
},
"auth": { "title": "Link Google Account" },
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Google Sheets integration needs to re-authenticate your account"
@ -23,7 +21,10 @@
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"create_spreadsheet_failure": "Error while creating spreadsheet, see error log for details",
"open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details"
"open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "Successfully authenticated and spreadsheet created at: {url}"

View File

@ -17,7 +17,10 @@
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"access_not_configured": "Unable to access the Google API:\n\n{message}",
"unknown": "[%key:common::config_flow::error::unknown%]"
"unknown": "[%key:common::config_flow::error::unknown%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -7,7 +7,11 @@
},
"abort": {
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]"
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
@ -22,22 +26,13 @@
"name": "Device ID",
"description": "Id of the device."
},
"program": {
"name": "Program",
"description": "Program to select."
},
"key": {
"name": "Option key",
"description": "Key of the option."
},
"program": { "name": "Program", "description": "Program to select." },
"key": { "name": "Option key", "description": "Key of the option." },
"value": {
"name": "Option value",
"description": "Value of the option."
},
"unit": {
"name": "Option unit",
"description": "Unit for the option."
}
"unit": { "name": "Option unit", "description": "Unit for the option." }
}
},
"select_program": {
@ -130,14 +125,8 @@
"name": "Device ID",
"description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]"
},
"key": {
"name": "Key",
"description": "Key of the setting."
},
"value": {
"name": "Value",
"description": "Value of the setting."
}
"key": { "name": "Key", "description": "Key of the setting." },
"value": { "name": "Value", "description": "Value of the setting." }
}
}
}

View File

@ -11,7 +11,11 @@
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -41,7 +41,11 @@
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"reauth_device_not_found": "The device you are trying to re-authenticate is not found in this LaMetric account",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
"unknown": "[%key:common::config_flow::error::unknown%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
}
},
"entity": {

View File

@ -12,7 +12,11 @@
"abort": {
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -13,7 +13,11 @@
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -49,7 +49,11 @@
"unknown_authorize_url_generation": "[%key:common::config_flow::abort::unknown_authorize_url_generation%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]"
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -14,7 +14,11 @@
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -7,7 +7,11 @@
},
"abort": {
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]"
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -11,7 +11,10 @@
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]"
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -29,7 +29,11 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"invalid_mdns": "Unsupported device for the Smappee integration.",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]"
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
}
}
}

View File

@ -13,7 +13,11 @@
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "The Spotify integration is not configured. Please follow the documentation.",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"reauth_account_mismatch": "The Spotify account authenticated with, does not match the account needed re-authentication."
"reauth_account_mismatch": "The Spotify account authenticated with, does not match the account needed re-authentication.",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "Successfully authenticated with Spotify."

View File

@ -18,7 +18,11 @@
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_agreements": "This account has no Toon displays.",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]"
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
}
},
"services": {

View File

@ -10,7 +10,11 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"wrong_account": "Wrong account: Please authenticate with {username}."
"wrong_account": "Wrong account: Please authenticate with {username}.",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
}
},
"issues": {

View File

@ -17,7 +17,10 @@
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"already_configured": "Configuration updated for profile.",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]"
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "Successfully authenticated with Withings."

View File

@ -8,7 +8,11 @@
"abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]"
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -16,7 +16,10 @@
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
@ -36,21 +39,11 @@
},
"entity": {
"switch": {
"usb_ports": {
"name": "USB ports"
},
"plug_1": {
"name": "Plug 1"
},
"plug_2": {
"name": "Plug 2"
},
"plug_3": {
"name": "Plug 3"
},
"plug_4": {
"name": "Plug 4"
}
"usb_ports": { "name": "USB ports" },
"plug_1": { "name": "Plug 1" },
"plug_2": { "name": "Plug 2" },
"plug_3": { "name": "Plug 3" },
"plug_4": { "name": "Plug 4" }
},
"sensor": {
"power_failure_alarm": {
@ -63,18 +56,11 @@
},
"power_failure_alarm_mute": {
"name": "Power failure alarm mute",
"state": {
"muted": "Muted",
"unmuted": "Unmuted"
}
"state": { "muted": "Muted", "unmuted": "Unmuted" }
},
"power_failure_alarm_volume": {
"name": "Power failure alarm volume",
"state": {
"low": "Low",
"medium": "Medium",
"high": "High"
}
"state": { "low": "Low", "medium": "Medium", "high": "High" }
},
"power_failure_alarm_beep": {
"name": "Power failure alarm beep",

View File

@ -6,7 +6,11 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"no_subscriptions": "You need to be subscribed to YouTube channels in order to add them.",
"unknown": "[%key:common::config_flow::error::unknown%]"
"unknown": "[%key:common::config_flow::error::unknown%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]"
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
@ -15,9 +19,7 @@
"step": {
"channels": {
"description": "Select the channels you want to add.",
"data": {
"channels": "YouTube channels"
}
"data": { "channels": "YouTube channels" }
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
@ -40,17 +42,11 @@
"latest_upload": {
"name": "Latest upload",
"state_attributes": {
"video_id": {
"name": "Video ID"
},
"published_at": {
"name": "Published at"
}
"video_id": { "name": "Video ID" },
"published_at": { "name": "Published at" }
}
},
"subscribers": {
"name": "Subscribers"
}
"subscribers": { "name": "Subscribers" }
}
}
}

View File

@ -10,12 +10,14 @@ from __future__ import annotations
from abc import ABC, ABCMeta, abstractmethod
import asyncio
from collections.abc import Awaitable, Callable
from http import HTTPStatus
from json import JSONDecodeError
import logging
import secrets
import time
from typing import Any, cast
from aiohttp import client, web
from aiohttp import ClientError, ClientResponseError, client, web
import jwt
import voluptuous as vol
from yarl import URL
@ -199,12 +201,15 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation):
_LOGGER.debug("Sending token request to %s", self.token_url)
resp = await session.post(self.token_url, data=data)
if resp.status >= 400 and _LOGGER.isEnabledFor(logging.DEBUG):
body = await resp.text()
_LOGGER.debug(
"Token request failed with status=%s, body=%s",
resp.status,
body,
if resp.status >= 400:
try:
error_response = await resp.json()
except (ClientError, JSONDecodeError):
error_response = {}
error_code = error_response.get("error", "unknown")
error_description = error_response.get("error_description", "unknown error")
_LOGGER.error(
"Token request failed (%s): %s", error_code, error_description
)
resp.raise_for_status()
return cast(dict, await resp.json())
@ -317,7 +322,14 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta):
)
except asyncio.TimeoutError as err:
_LOGGER.error("Timeout resolving OAuth token: %s", err)
return self.async_abort(reason="oauth2_timeout")
return self.async_abort(reason="oauth_timeout")
except (ClientResponseError, ClientError) as err:
if (
isinstance(err, ClientResponseError)
and err.status == HTTPStatus.UNAUTHORIZED
):
return self.async_abort(reason="oauth_unauthorized")
return self.async_abort(reason="oauth_failed")
if "expires_in" not in token:
_LOGGER.warning("Invalid token: %s", token)

View File

@ -126,6 +126,8 @@
"oauth2_authorize_url_timeout": "Timeout generating authorize URL.",
"oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})",
"oauth2_user_rejected_authorize": "Account linking rejected: {error}",
"oauth2_unauthorized": "OAuth authorization error while obtaining access token.",
"oauth2_failed": "Error while obtaining access token.",
"reauth_successful": "Re-authentication was successful",
"unknown_authorize_url_generation": "Unknown error generating an authorize URL.",
"cloud_not_connected": "Not connected to Home Assistant Cloud."

View File

@ -185,6 +185,9 @@ def _custom_tasks(template, info: Info) -> None:
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",

View File

@ -1,6 +1,7 @@
"""Test the Google Nest Device Access config flow."""
from __future__ import annotations
from http import HTTPStatus
from typing import Any
from unittest.mock import patch
@ -27,7 +28,9 @@ from .common import (
SUBSCRIBER_ID,
TEST_CONFIG_APP_CREDS,
TEST_CONFIGFLOW_APP_CREDS,
FakeSubscriber,
NestTestConfig,
PlatformSetup,
)
from tests.common import MockConfigEntry
@ -92,8 +95,6 @@ class OAuthFixture:
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
await self.async_mock_refresh(result)
async def async_reauth(self, config_entry: ConfigEntry) -> dict:
"""Initiate a reuath flow."""
config_entry.async_start_reauth(self.hass)
@ -137,7 +138,7 @@ class OAuthFixture:
"&access_type=offline&prompt=consent"
)
async def async_mock_refresh(self, result, user_input: dict = None) -> None:
def async_mock_refresh(self) -> None:
"""Finish the OAuth flow exchanging auth token for refresh token."""
self.aioclient_mock.post(
OAUTH2_TOKEN,
@ -202,6 +203,7 @@ async def test_app_credentials(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await oauth.async_app_creds_flow(result)
oauth.async_mock_refresh()
entry = await oauth.async_finish_setup(result)
@ -235,6 +237,7 @@ async def test_config_flow_restart(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await oauth.async_app_creds_flow(result)
oauth.async_mock_refresh()
# At this point, we should have a valid auth implementation configured.
# Simulate aborting the flow and starting over to ensure we get prompted
@ -254,6 +257,7 @@ async def test_config_flow_restart(
result = await oauth.async_configure(result, {"project_id": "new-project-id"})
await oauth.async_oauth_web_flow(result, "new-project-id")
oauth.async_mock_refresh()
entry = await oauth.async_finish_setup(result, {"code": "1234"})
@ -305,6 +309,7 @@ async def test_config_flow_wrong_project_id(
result = await oauth.async_configure(result, {"project_id": PROJECT_ID})
await oauth.async_oauth_web_flow(result)
await hass.async_block_till_done()
oauth.async_mock_refresh()
entry = await oauth.async_finish_setup(result, {"code": "1234"})
@ -341,6 +346,7 @@ async def test_config_flow_pubsub_configuration_error(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await oauth.async_app_creds_flow(result)
oauth.async_mock_refresh()
mock_subscriber.create_subscription.side_effect = ConfigurationException
result = await oauth.async_configure(result, {"code": "1234"})
@ -360,6 +366,7 @@ async def test_config_flow_pubsub_subscriber_error(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await oauth.async_app_creds_flow(result)
oauth.async_mock_refresh()
mock_subscriber.create_subscription.side_effect = SubscriberException()
result = await oauth.async_configure(result, {"code": "1234"})
@ -384,6 +391,7 @@ async def test_multiple_config_entries(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await oauth.async_app_creds_flow(result, project_id="project-id-2")
oauth.async_mock_refresh()
entry = await oauth.async_finish_setup(result)
assert entry.title == "Mock Title"
assert "token" in entry.data
@ -442,6 +450,7 @@ async def test_reauth_multiple_config_entries(
result = await oauth.async_reauth(config_entry)
await oauth.async_oauth_web_flow(result)
oauth.async_mock_refresh()
await oauth.async_finish_setup(result)
@ -479,6 +488,7 @@ async def test_pubsub_subscription_strip_whitespace(
await oauth.async_app_creds_flow(
result, cloud_project_id=" " + CLOUD_PROJECT_ID + " "
)
oauth.async_mock_refresh()
entry = await oauth.async_finish_setup(result, {"code": "1234"})
assert entry.title == "Import from configuration.yaml"
@ -508,6 +518,7 @@ async def test_pubsub_subscription_auth_failure(
mock_subscriber.create_subscription.side_effect = AuthException()
await oauth.async_app_creds_flow(result)
oauth.async_mock_refresh()
result = await oauth.async_configure(result, {"code": "1234"})
assert result["type"] == "abort"
@ -527,6 +538,7 @@ async def test_pubsub_subscriber_config_entry_reauth(
result = await oauth.async_reauth(config_entry)
await oauth.async_oauth_web_flow(result)
oauth.async_mock_refresh()
# Entering an updated access token refreshes the config entry.
entry = await oauth.async_finish_setup(result, {"code": "1234"})
@ -568,6 +580,7 @@ async def test_config_entry_title_from_home(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await oauth.async_app_creds_flow(result)
oauth.async_mock_refresh()
entry = await oauth.async_finish_setup(result, {"code": "1234"})
assert entry.title == "Example Home"
@ -613,6 +626,7 @@ async def test_config_entry_title_multiple_homes(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await oauth.async_app_creds_flow(result)
oauth.async_mock_refresh()
entry = await oauth.async_finish_setup(result, {"code": "1234"})
assert entry.title == "Example Home #1, Example Home #2"
@ -628,6 +642,7 @@ async def test_title_failure_fallback(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await oauth.async_app_creds_flow(result)
oauth.async_mock_refresh()
mock_subscriber.async_get_device_manager.side_effect = AuthException()
entry = await oauth.async_finish_setup(result, {"code": "1234"})
@ -659,6 +674,7 @@ async def test_structure_missing_trait(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await oauth.async_app_creds_flow(result)
oauth.async_mock_refresh()
entry = await oauth.async_finish_setup(result, {"code": "1234"})
# Fallback to default name
@ -705,6 +721,7 @@ async def test_dhcp_discovery_with_creds(
result = await oauth.async_configure(result, {"project_id": PROJECT_ID})
await oauth.async_oauth_web_flow(result)
oauth.async_mock_refresh()
entry = await oauth.async_finish_setup(result, {"code": "1234"})
await hass.async_block_till_done()
@ -726,3 +743,36 @@ async def test_dhcp_discovery_with_creds(
"type": "Bearer",
},
}
@pytest.mark.parametrize(
("status_code", "error_reason"),
[
(HTTPStatus.UNAUTHORIZED, "oauth_unauthorized"),
(HTTPStatus.NOT_FOUND, "oauth_failed"),
(HTTPStatus.INTERNAL_SERVER_ERROR, "oauth_failed"),
],
)
async def test_token_error(
hass: HomeAssistant,
oauth: OAuthFixture,
subscriber: FakeSubscriber,
setup_platform: PlatformSetup,
status_code: HTTPStatus,
error_reason: str,
) -> None:
"""Check full flow."""
await setup_platform()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await oauth.async_app_creds_flow(result)
oauth.aioclient_mock.post(
OAUTH2_TOKEN,
status=status_code,
)
result = await oauth.async_configure(result, user_input=None)
assert result.get("type") == "abort"
assert result.get("reason") == error_reason

View File

@ -1,7 +1,9 @@
"""Tests for the Somfy config flow."""
import asyncio
from http import HTTPStatus
import logging
import time
from typing import Any
from unittest.mock import patch
import aiohttp
@ -339,7 +341,7 @@ async def test_abort_on_oauth_timeout_error(
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == data_entry_flow.FlowResultType.ABORT
assert result["reason"] == "oauth2_timeout"
assert result["reason"] == "oauth_timeout"
async def test_step_discovery(hass: HomeAssistant, flow_handler, local_impl) -> None:
@ -387,6 +389,164 @@ async def test_abort_discovered_multiple(
assert result["reason"] == "already_in_progress"
@pytest.mark.parametrize(
("status_code", "error_body", "error_reason", "error_log"),
[
(
HTTPStatus.UNAUTHORIZED,
{},
"oauth_unauthorized",
"Token request failed (unknown): unknown",
),
(
HTTPStatus.NOT_FOUND,
{},
"oauth_failed",
"Token request failed (unknown): unknown",
),
(
HTTPStatus.INTERNAL_SERVER_ERROR,
{},
"oauth_failed",
"Token request failed (unknown): unknown",
),
(
HTTPStatus.BAD_REQUEST,
{
"error": "invalid_request",
"error_description": "Request was missing the 'redirect_uri' parameter.",
"error_uri": "See the full API docs at https://authorization-server.com/docs/access_token",
},
"oauth_failed",
"Token request failed (invalid_request): Request was missing the",
),
],
)
async def test_abort_if_oauth_token_error(
hass: HomeAssistant,
flow_handler,
local_impl,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
current_request_with_host: None,
status_code: HTTPStatus,
error_body: dict[str, Any],
error_reason: str,
error_log: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Check error when obtaining an oauth token."""
flow_handler.async_register_implementation(hass, local_impl)
config_entry_oauth2_flow.async_register_implementation(
hass, TEST_DOMAIN, MockOAuth2Implementation()
)
result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "pick_implementation"
# Pick implementation
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"implementation": TEST_DOMAIN}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP
assert result["url"] == (
f"{AUTHORIZE_URL}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}&scope=read+write"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
TOKEN_URL,
status=status_code,
json=error_body,
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == data_entry_flow.FlowResultType.ABORT
assert result["reason"] == error_reason
assert error_log in caplog.text
async def test_abort_if_oauth_token_closing_error(
hass: HomeAssistant,
flow_handler,
local_impl,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
current_request_with_host: None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Check error when obtaining an oauth token."""
flow_handler.async_register_implementation(hass, local_impl)
config_entry_oauth2_flow.async_register_implementation(
hass, TEST_DOMAIN, MockOAuth2Implementation()
)
result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "pick_implementation"
# Pick implementation
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"implementation": TEST_DOMAIN}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP
assert result["url"] == (
f"{AUTHORIZE_URL}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}&scope=read+write"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
TOKEN_URL,
status=HTTPStatus.UNAUTHORIZED,
closing=True,
)
with caplog.at_level(logging.DEBUG):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert "Token request failed (unknown): unknown" in caplog.text
assert result["type"] == data_entry_flow.FlowResultType.ABORT
assert result["reason"] == "oauth_unauthorized"
async def test_abort_discovered_existing_entries(
hass: HomeAssistant, flow_handler, local_impl
) -> None:

View File

@ -7,7 +7,11 @@ from unittest import mock
from urllib.parse import parse_qs
from aiohttp import ClientSession
from aiohttp.client_exceptions import ClientError, ClientResponseError
from aiohttp.client_exceptions import (
ClientConnectionError,
ClientError,
ClientResponseError,
)
from aiohttp.streams import StreamReader
from multidict import CIMultiDict
from yarl import URL
@ -53,6 +57,7 @@ class AiohttpClientMocker:
exc=None,
cookies=None,
side_effect=None,
closing=None,
):
"""Mock a request."""
if not isinstance(url, RETYPE):
@ -72,6 +77,7 @@ class AiohttpClientMocker:
exc=exc,
headers=headers,
side_effect=side_effect,
closing=closing,
)
)
@ -165,6 +171,7 @@ class AiohttpClientMockResponse:
exc=None,
headers=None,
side_effect=None,
closing=None,
):
"""Initialize a fake response."""
if json is not None:
@ -178,9 +185,10 @@ class AiohttpClientMockResponse:
self.method = method
self._url = url
self.status = status
self.response = response
self._response = response
self.exc = exc
self.side_effect = side_effect
self.closing = closing
self._headers = CIMultiDict(headers or {})
self._cookies = {}
@ -272,6 +280,13 @@ class AiohttpClientMockResponse:
def close(self):
"""Mock close."""
@property
def response(self):
"""Property method to expose the response to other read methods."""
if self.closing:
raise ClientConnectionError("Connection closed")
return self._response
@contextmanager
def mock_aiohttp_client():