Handle expiration of nest auth credentials (#44202)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
5bdf022bf2
commit
81341bbf91
4 changed files with 262 additions and 55 deletions
|
@ -6,14 +6,14 @@ import logging
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from google_nest_sdm.event import AsyncEventCallback, EventMessage
|
from google_nest_sdm.event import AsyncEventCallback, EventMessage
|
||||||
from google_nest_sdm.exceptions import GoogleNestException
|
from google_nest_sdm.exceptions import AuthException, GoogleNestException
|
||||||
from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber
|
from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber
|
||||||
from nest import Nest
|
from nest import Nest
|
||||||
from nest.nest import APIError, AuthorizationError
|
from nest.nest import APIError, AuthorizationError
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_BINARY_SENSORS,
|
CONF_BINARY_SENSORS,
|
||||||
CONF_CLIENT_ID,
|
CONF_CLIENT_ID,
|
||||||
|
@ -231,6 +231,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await subscriber.start_async()
|
await subscriber.start_async()
|
||||||
|
except AuthException as err:
|
||||||
|
_LOGGER.debug("Subscriber authentication error: %s", err)
|
||||||
|
hass.async_create_task(
|
||||||
|
hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_REAUTH},
|
||||||
|
data=entry.data,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return False
|
||||||
except GoogleNestException as err:
|
except GoogleNestException as err:
|
||||||
_LOGGER.error("Subscriber error: %s", err)
|
_LOGGER.error("Subscriber error: %s", err)
|
||||||
subscriber.stop_async()
|
subscriber.stop_async()
|
||||||
|
|
|
@ -75,6 +75,12 @@ class NestFlowHandler(
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
|
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize NestFlowHandler."""
|
||||||
|
super().__init__()
|
||||||
|
# When invoked for reauth, allows updating an existing config entry
|
||||||
|
self._reauth = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def register_sdm_api(cls, hass):
|
def register_sdm_api(cls, hass):
|
||||||
"""Configure the flow handler to use the SDM API."""
|
"""Configure the flow handler to use the SDM API."""
|
||||||
|
@ -103,19 +109,56 @@ class NestFlowHandler(
|
||||||
|
|
||||||
async def async_oauth_create_entry(self, data: dict) -> dict:
|
async def async_oauth_create_entry(self, data: dict) -> dict:
|
||||||
"""Create an entry for the SDM flow."""
|
"""Create an entry for the SDM flow."""
|
||||||
|
assert self.is_sdm_api(), "Step only supported for SDM API"
|
||||||
data[DATA_SDM] = {}
|
data[DATA_SDM] = {}
|
||||||
|
await self.async_set_unique_id(DOMAIN)
|
||||||
|
# Update existing config entry when in the reauth flow. This
|
||||||
|
# integration only supports one config entry so remove any prior entries
|
||||||
|
# added before the "single_instance_allowed" check was added
|
||||||
|
existing_entries = self.hass.config_entries.async_entries(DOMAIN)
|
||||||
|
if existing_entries:
|
||||||
|
updated = False
|
||||||
|
for entry in existing_entries:
|
||||||
|
if updated:
|
||||||
|
await self.hass.config_entries.async_remove(entry.entry_id)
|
||||||
|
continue
|
||||||
|
updated = True
|
||||||
|
self.hass.config_entries.async_update_entry(
|
||||||
|
entry, data=data, unique_id=DOMAIN
|
||||||
|
)
|
||||||
|
await self.hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
return self.async_abort(reason="reauth_successful")
|
||||||
|
|
||||||
return await super().async_oauth_create_entry(data)
|
return await super().async_oauth_create_entry(data)
|
||||||
|
|
||||||
|
async def async_step_reauth(self, user_input=None):
|
||||||
|
"""Perform reauth upon an API authentication error."""
|
||||||
|
assert self.is_sdm_api(), "Step only supported for SDM API"
|
||||||
|
self._reauth = True # Forces update of existing config entry
|
||||||
|
return await self.async_step_reauth_confirm()
|
||||||
|
|
||||||
|
async def async_step_reauth_confirm(self, user_input=None):
|
||||||
|
"""Confirm reauth dialog."""
|
||||||
|
assert self.is_sdm_api(), "Step only supported for SDM API"
|
||||||
|
if user_input is None:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="reauth_confirm",
|
||||||
|
data_schema=vol.Schema({}),
|
||||||
|
)
|
||||||
|
return await self.async_step_user()
|
||||||
|
|
||||||
async def async_step_user(self, user_input=None):
|
async def async_step_user(self, user_input=None):
|
||||||
"""Handle a flow initialized by the user."""
|
"""Handle a flow initialized by the user."""
|
||||||
if self.is_sdm_api():
|
if self.is_sdm_api():
|
||||||
|
# Reauth will update an existing entry
|
||||||
|
if self.hass.config_entries.async_entries(DOMAIN) and not self._reauth:
|
||||||
|
return self.async_abort(reason="single_instance_allowed")
|
||||||
return await super().async_step_user(user_input)
|
return await super().async_step_user(user_input)
|
||||||
return await self.async_step_init(user_input)
|
return await self.async_step_init(user_input)
|
||||||
|
|
||||||
async def async_step_init(self, user_input=None):
|
async def async_step_init(self, user_input=None):
|
||||||
"""Handle a flow start."""
|
"""Handle a flow start."""
|
||||||
if self.is_sdm_api():
|
assert not self.is_sdm_api(), "Step only supported for legacy API"
|
||||||
raise UnexpectedStateError("Step only supported for legacy API")
|
|
||||||
|
|
||||||
flows = self.hass.data.get(DATA_FLOW_IMPL, {})
|
flows = self.hass.data.get(DATA_FLOW_IMPL, {})
|
||||||
|
|
||||||
|
@ -145,8 +188,7 @@ class NestFlowHandler(
|
||||||
implementation type we expect a pin or an external component to
|
implementation type we expect a pin or an external component to
|
||||||
deliver the authentication code.
|
deliver the authentication code.
|
||||||
"""
|
"""
|
||||||
if self.is_sdm_api():
|
assert not self.is_sdm_api(), "Step only supported for legacy API"
|
||||||
raise UnexpectedStateError("Step only supported for legacy API")
|
|
||||||
|
|
||||||
flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl]
|
flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl]
|
||||||
|
|
||||||
|
@ -188,8 +230,7 @@ class NestFlowHandler(
|
||||||
|
|
||||||
async def async_step_import(self, info):
|
async def async_step_import(self, info):
|
||||||
"""Import existing auth from Nest."""
|
"""Import existing auth from Nest."""
|
||||||
if self.is_sdm_api():
|
assert not self.is_sdm_api(), "Step only supported for legacy API"
|
||||||
raise UnexpectedStateError("Step only supported for legacy API")
|
|
||||||
|
|
||||||
if self.hass.config_entries.async_entries(DOMAIN):
|
if self.hass.config_entries.async_entries(DOMAIN):
|
||||||
return self.async_abort(reason="single_instance_allowed")
|
return self.async_abort(reason="single_instance_allowed")
|
||||||
|
|
|
@ -4,6 +4,10 @@
|
||||||
"pick_implementation": {
|
"pick_implementation": {
|
||||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||||
},
|
},
|
||||||
|
"reauth_confirm": {
|
||||||
|
"title": "[%key:common::config_flow::title::reauth%]",
|
||||||
|
"description": "The Nest integration needs to re-authenticate your account"
|
||||||
|
},
|
||||||
"init": {
|
"init": {
|
||||||
"title": "Authentication Provider",
|
"title": "Authentication Provider",
|
||||||
"description": "[%key:common::config_flow::title::oauth2_pick_implementation%]",
|
"description": "[%key:common::config_flow::title::oauth2_pick_implementation%]",
|
||||||
|
@ -30,7 +34,8 @@
|
||||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
"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%]",
|
||||||
"unknown_authorize_url_generation": "[%key:common::config_flow::abort::unknown_authorize_url_generation%]",
|
"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%]"
|
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||||
|
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||||
},
|
},
|
||||||
"create_entry": {
|
"create_entry": {
|
||||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
"""Test the Google Nest Device Access config flow."""
|
"""Test the Google Nest Device Access config flow."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from homeassistant import config_entries, setup
|
from homeassistant import config_entries, setup
|
||||||
from homeassistant.components.nest.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN
|
from homeassistant.components.nest.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN
|
||||||
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
|
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
|
||||||
from homeassistant.helpers import config_entry_oauth2_flow
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
|
|
||||||
|
from .common import MockConfigEntry
|
||||||
|
|
||||||
from tests.async_mock import patch
|
from tests.async_mock import patch
|
||||||
|
|
||||||
CLIENT_ID = "1234"
|
CLIENT_ID = "1234"
|
||||||
|
@ -11,64 +16,210 @@ CLIENT_SECRET = "5678"
|
||||||
PROJECT_ID = "project-id-4321"
|
PROJECT_ID = "project-id-4321"
|
||||||
SUBSCRIBER_ID = "subscriber-id-9876"
|
SUBSCRIBER_ID = "subscriber-id-9876"
|
||||||
|
|
||||||
|
CONFIG = {
|
||||||
|
DOMAIN: {
|
||||||
|
"project_id": PROJECT_ID,
|
||||||
|
"subscriber_id": SUBSCRIBER_ID,
|
||||||
|
CONF_CLIENT_ID: CLIENT_ID,
|
||||||
|
CONF_CLIENT_SECRET: CLIENT_SECRET,
|
||||||
|
},
|
||||||
|
"http": {"base_url": "https://example.com"},
|
||||||
|
}
|
||||||
|
|
||||||
async def test_full_flow(
|
|
||||||
hass, aiohttp_client, aioclient_mock, current_request_with_host
|
def get_config_entry(hass):
|
||||||
):
|
"""Return a single config entry."""
|
||||||
"""Check full flow."""
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
assert await setup.async_setup_component(
|
assert len(entries) == 1
|
||||||
hass,
|
return entries[0]
|
||||||
DOMAIN,
|
|
||||||
{
|
|
||||||
DOMAIN: {
|
class OAuthFixture:
|
||||||
"project_id": PROJECT_ID,
|
"""Simulate the oauth flow used by the config flow."""
|
||||||
"subscriber_id": SUBSCRIBER_ID,
|
|
||||||
CONF_CLIENT_ID: CLIENT_ID,
|
def __init__(self, hass, aiohttp_client, aioclient_mock):
|
||||||
CONF_CLIENT_SECRET: CLIENT_SECRET,
|
"""Initialize OAuthFixture."""
|
||||||
|
self.hass = hass
|
||||||
|
self.aiohttp_client = aiohttp_client
|
||||||
|
self.aioclient_mock = aioclient_mock
|
||||||
|
|
||||||
|
async def async_oauth_flow(self, result):
|
||||||
|
"""Invoke the oauth flow with fake responses."""
|
||||||
|
state = config_entry_oauth2_flow._encode_jwt(
|
||||||
|
self.hass,
|
||||||
|
{
|
||||||
|
"flow_id": result["flow_id"],
|
||||||
|
"redirect_uri": "https://example.com/auth/external/callback",
|
||||||
},
|
},
|
||||||
"http": {"base_url": "https://example.com"},
|
)
|
||||||
},
|
|
||||||
)
|
oauth_authorize = OAUTH2_AUTHORIZE.format(project_id=PROJECT_ID)
|
||||||
|
assert result["type"] == "external"
|
||||||
|
assert result["url"] == (
|
||||||
|
f"{oauth_authorize}?response_type=code&client_id={CLIENT_ID}"
|
||||||
|
"&redirect_uri=https://example.com/auth/external/callback"
|
||||||
|
f"&state={state}&scope=https://www.googleapis.com/auth/sdm.service"
|
||||||
|
"+https://www.googleapis.com/auth/pubsub"
|
||||||
|
"&access_type=offline&prompt=consent"
|
||||||
|
)
|
||||||
|
|
||||||
|
client = await self.aiohttp_client(self.hass.http.app)
|
||||||
|
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"
|
||||||
|
|
||||||
|
self.aioclient_mock.post(
|
||||||
|
OAUTH2_TOKEN,
|
||||||
|
json={
|
||||||
|
"refresh_token": "mock-refresh-token",
|
||||||
|
"access_token": "mock-access-token",
|
||||||
|
"type": "Bearer",
|
||||||
|
"expires_in": 60,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.nest.async_setup_entry", return_value=True
|
||||||
|
) as mock_setup:
|
||||||
|
await self.hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
assert len(mock_setup.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def oauth(hass, aiohttp_client, aioclient_mock, current_request_with_host):
|
||||||
|
"""Create the simulated oauth flow."""
|
||||||
|
return OAuthFixture(hass, aiohttp_client, aioclient_mock)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_flow(hass, oauth):
|
||||||
|
"""Check full flow."""
|
||||||
|
assert await setup.async_setup_component(hass, DOMAIN, CONFIG)
|
||||||
|
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
)
|
)
|
||||||
state = config_entry_oauth2_flow._encode_jwt(
|
await oauth.async_oauth_flow(result)
|
||||||
hass,
|
|
||||||
{
|
entry = get_config_entry(hass)
|
||||||
"flow_id": result["flow_id"],
|
assert entry.title == "Configuration.yaml"
|
||||||
"redirect_uri": "https://example.com/auth/external/callback",
|
assert "token" in entry.data
|
||||||
|
entry.data["token"].pop("expires_at")
|
||||||
|
assert entry.unique_id == DOMAIN
|
||||||
|
assert entry.data["token"] == {
|
||||||
|
"refresh_token": "mock-refresh-token",
|
||||||
|
"access_token": "mock-access-token",
|
||||||
|
"type": "Bearer",
|
||||||
|
"expires_in": 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reauth(hass, oauth):
|
||||||
|
"""Test Nest reauthentication."""
|
||||||
|
|
||||||
|
assert await setup.async_setup_component(hass, DOMAIN, CONFIG)
|
||||||
|
|
||||||
|
old_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={
|
||||||
|
"auth_implementation": DOMAIN,
|
||||||
|
"token": {
|
||||||
|
# Verify this is replaced at end of the test
|
||||||
|
"access_token": "some-revoked-token",
|
||||||
|
},
|
||||||
|
"sdm": {},
|
||||||
},
|
},
|
||||||
|
unique_id=DOMAIN,
|
||||||
|
)
|
||||||
|
old_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
entry = get_config_entry(hass)
|
||||||
|
assert entry.data["token"] == {
|
||||||
|
"access_token": "some-revoked-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=old_entry.data
|
||||||
)
|
)
|
||||||
|
|
||||||
oauth_authorize = OAUTH2_AUTHORIZE.format(project_id=PROJECT_ID)
|
# Advance through the reauth flow
|
||||||
assert result["url"] == (
|
flows = hass.config_entries.flow.async_progress()
|
||||||
f"{oauth_authorize}?response_type=code&client_id={CLIENT_ID}"
|
assert len(flows) == 1
|
||||||
"&redirect_uri=https://example.com/auth/external/callback"
|
assert flows[0]["step_id"] == "reauth_confirm"
|
||||||
f"&state={state}&scope=https://www.googleapis.com/auth/sdm.service"
|
|
||||||
"+https://www.googleapis.com/auth/pubsub"
|
# Run the oauth flow
|
||||||
"&access_type=offline&prompt=consent"
|
result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {})
|
||||||
|
await oauth.async_oauth_flow(result)
|
||||||
|
|
||||||
|
# Verify existing tokens are replaced
|
||||||
|
entry = get_config_entry(hass)
|
||||||
|
entry.data["token"].pop("expires_at")
|
||||||
|
assert entry.unique_id == DOMAIN
|
||||||
|
assert entry.data["token"] == {
|
||||||
|
"refresh_token": "mock-refresh-token",
|
||||||
|
"access_token": "mock-access-token",
|
||||||
|
"type": "Bearer",
|
||||||
|
"expires_in": 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_single_config_entry(hass):
|
||||||
|
"""Test that only a single config entry is allowed."""
|
||||||
|
old_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN, data={"auth_implementation": DOMAIN, "sdm": {}}
|
||||||
)
|
)
|
||||||
|
old_entry.add_to_hass(hass)
|
||||||
|
|
||||||
client = await aiohttp_client(hass.http.app)
|
assert await setup.async_setup_component(hass, DOMAIN, CONFIG)
|
||||||
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(
|
result = await hass.config_entries.flow.async_init(
|
||||||
OAUTH2_TOKEN,
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
json={
|
|
||||||
"refresh_token": "mock-refresh-token",
|
|
||||||
"access_token": "mock-access-token",
|
|
||||||
"type": "Bearer",
|
|
||||||
"expires_in": 60,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
assert result["type"] == "abort"
|
||||||
|
assert result["reason"] == "single_instance_allowed"
|
||||||
|
|
||||||
with patch(
|
|
||||||
"homeassistant.components.nest.async_setup_entry", return_value=True
|
|
||||||
) as mock_setup:
|
|
||||||
await hass.config_entries.flow.async_configure(result["flow_id"])
|
|
||||||
|
|
||||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
async def test_unexpected_existing_config_entries(hass, oauth):
|
||||||
assert len(mock_setup.mock_calls) == 1
|
"""Test Nest reauthentication with multiple existing config entries."""
|
||||||
|
# Note that this case will not happen in the future since only a single
|
||||||
|
# instance is now allowed, but this may have been allowed in the past.
|
||||||
|
# On reauth, only one entry is kept and the others are deleted.
|
||||||
|
|
||||||
|
assert await setup.async_setup_component(hass, DOMAIN, CONFIG)
|
||||||
|
|
||||||
|
old_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN, data={"auth_implementation": DOMAIN, "sdm": {}}
|
||||||
|
)
|
||||||
|
old_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
old_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN, data={"auth_implementation": DOMAIN, "sdm": {}}
|
||||||
|
)
|
||||||
|
old_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
|
assert len(entries) == 2
|
||||||
|
|
||||||
|
# Invoke the reauth flow
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=old_entry.data
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "reauth_confirm"
|
||||||
|
|
||||||
|
flows = hass.config_entries.flow.async_progress()
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {})
|
||||||
|
await oauth.async_oauth_flow(result)
|
||||||
|
|
||||||
|
# Only a single entry now exists, and the other was cleaned up
|
||||||
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
|
assert len(entries) == 1
|
||||||
|
entry = entries[0]
|
||||||
|
assert entry.unique_id == DOMAIN
|
||||||
|
entry.data["token"].pop("expires_at")
|
||||||
|
assert entry.data["token"] == {
|
||||||
|
"refresh_token": "mock-refresh-token",
|
||||||
|
"access_token": "mock-access-token",
|
||||||
|
"type": "Bearer",
|
||||||
|
"expires_in": 60,
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue