OAuth Flow for Oura API

Hello everyone,

I am working on a custom component for Oura. I have it working for both pulling data and renewing the API token.

However, I am struggling on how to create a first time set up with the API Oauth on Hassio.

The API OAuth process is:

  1. Call Oura server with client id and secret. Oura opens an OAuth consent window on their page that the user manually accepts.
  2. Oura redirects back to your site with a code.
  3. Call Oura with Oauth code. Oura replies with authorization token.
  4. Once received, I can store it.

My struggle is:
I can make the first API call (step 1). However, I need to be able to receive the code after the user has gone to Oura site. I am unsure how that step would look like.

In particular, to which URL do I redirect? How do I listen to the code in the reply to trigger the last call? Any good examples out there?

I somehow achieved something similar with the Google Fit OAuth. However, on that one I was using oauth2client libraries which helped whereas here, I need to handle everything on my own and I am unsure how.

PS: To be sure, I know how to make all the calls outside Hassio, it’s the Hassio flow that I am unsure about.

Thanks,
Nito

1 Like

I’ve figured it out on my own. I am not sure if this is the best way to deal with this, but it works and might help someone in the future. The approach is based on Spotify’s native integration. This checks are not done on setup(), but I decided to do it on the sensor itself. However, the logic could be adapted.

When a Sensor is created, I also create an API class. This API has access_token and refresh_token as attributes:

  def __init__(self, sensor, client_id, client_secret):
    """Instantiates a new OuraApi class.

    Args:
      sensor: Oura sensor to which this api is linked.
      client_id: Client id for Oura API.
      client_secret: Client secret for Oura API.
    """
    self._sensor = sensor

    self._client_id = client_id
    self._client_secret = client_secret

    self._access_token = None
    self._refresh_token = None

If when fetching the data these parameters are not set up, we try fetching them from a local storage file:

  def get_sleep_data(self, start_date, end_date=None):
    """Fetches data for a sleep OuraEndpoint and date.

    Args:
      start_date: Day for which to fetch data(YYYY-MM-DD).
      end_date: Last day for which to retrieve data(YYYY-MM-DD).
        If same as start_date, leave empty.

    Returns:
      Dictionary containing Oura sleep data.
      None if the access token was not found or authorized.
    """
    if not self._access_token:
      self._get_access_token_data_from_file()

    # If after fetching the token, it is still not available, the update should
    # not go through. This is most likely at the OAuth set up stage and may
    # require input from the user.
    if not self._access_token:
      return None

The file could be in 3 status:

  1. The OAuth has not happened for this sensor. Hence, the OAuth needs to start.
  2. The file contains “code”. This means that this is on the second step of authentication and the code needs to get exchanged for a token.
  3. The file contains “access_code”, in which case this was already authenticated and we can just use the credentials normally.
  def _get_access_token_data_from_file(self):
    """Gets credentials data from the credentials file."""
    if not os.path.isfile(self.token_file_name):
      self._get_authentication_code()
      return

    with open(self.token_file_name, 'r') as token_file:
      token_data = json.loads(token_file.read()) or {}

    if token_data.get('code'):
      self._get_access_token_from_code(token_data.get('code'))
      return

    if token_data.get('access_token') and token_data.get('refresh_token'):
      self._access_token = token_data.get('access_token')
      self._refresh_token = token_data.get('refresh_token')

    logging.error('Unable to retrieve access token from file data.')

If the file does not exit, we trigger the OAuth flow to get the access code:

  def _get_authentication_code(self):
    """Gets authentication code."""
    base_url = self._sensor._hass.config.api.base_url
    callback_url = f'{base_url}{views.AUTH_CALLBACK_PATH}'
    state = self._sensor.name

    authorize_params = {
        'client_id': self._client_id,
        'duration': 'temporary',
        'redirect_uri': callback_url,
        'response_type': 'code',
        'scope': 'email personal daily',
        'state': state,
    }
    authorize_url = '{}?{}'.format(
        self._get_api_endpoint(OuraEndpoints.AUTHORIZE),
        urllib.parse.urlencode(authorize_params))

    self._sensor.create_oauth_view(authorize_url)

This will do three things:

  1. Create a permanent notification with the link to authorize.
  2. This link includes a callback URL which is the URL that will receive the code after approving the access.
  3. It creates a view to handle the callback.
  def create_oauth_view(self, authorize_url):
    """Creates a view and message to obtain authorization token.

    Args:
      authorize_url: Authorization URL.
    """
    self._hass.http.register_view(views.OuraAuthCallbackView(self))
    self._hass.components.persistent_notification.create(
        'In order to authorize Home-Assistant to view your Oura Ring data, '
        'you must visit: '
        f'<a href="{authorize_url}" target="_blank">{authorize_url}</a>',
        title=SENSOR_NAME,
        notification_id=f'oura_setup_{self._name}')

On the second call, we are creating a notification for the user. When the user clicks the “authorize_url”, it gets redirected to Oura website and comes back to the callback URL.

This call is handled by my OuraAuthCallbackView. This is a view that is set to handle the call exactly on the callback URL.

This view is responsible for receiving the code and storing it on the cache file for this sensor. As we are using sensor name as part of the file name, we could have different access tokens for different sensors (aka: different rings / accounts).

Note: This view part is what I really was struggling with. I was unsure how to handle the callback URL to continue the OAuth flow within the Home-Assistant ecosystem.

# Views configuration.
AUTH_CALLBACK_NAME = 'api:oura'
AUTH_CALLBACK_PATH = '/oura/oauth/setup'


class OuraAuthCallbackView(http.HomeAssistantView):
  """Oura Authorization Callback View.

  Methods:
    get: Handles get requests to given view.
  """

  requires_auth = False
  url = AUTH_CALLBACK_PATH
  name = AUTH_CALLBACK_NAME

  def __init__(self, sensor):
    """Initializes view.

    Args:
      sensor: Sensor which initialized the OAuth process.
    """
    self._sensor = sensor

  @core.callback
  def get(self, request):
    """Handles Oura OAuth callbacks.

    Stores code from Oura API into cache token file.
    This code will be read by the API and use it to retrieve access token.
    """
    code = request.query.get('code')
    code_data = {'code': code}

    sensor_name = request.query.get('state')
    token_file_name = self._sensor._api.token_file_name

    with open(token_file_name, 'w+') as token_file:
      token_file.write(json.dumps(code_data))

    self._sensor.update()  # Forces exchanging code for access token.

    return self.json_message(
        f'Oura OAuth code {code} for sensor.{sensor_name} stored in '
        f'{token_file_name}. The sensor API will use this code to retrieve '
        'the access token, store it and start fetching data on the next '
        'update. No further action is required from your side. Any errors on '
        'retrieving the API token will be logged. If you ever want to restart '
        f'this OAuth process, simply delete the file {token_file_name} within '
        'the /config/ directory.')

After storing the code in a file, the view updates the sensor. This will trigger again the OAuth process. However, this time, we have a token cache file with “code” on it. As a result, the code will call _get_access_token_from_code to exchange the code for an access token.

  def _get_access_token_from_code(self, code):
    """Requests and stores an access token for the given code.

    Args:
      code: Oura OAuth code.
    """
    access_token_data = self._request_access_token_with_code(code)
    self._store_access_token_data(access_token_data)

def _request_access_token_with_code(self, code):
    """Sends a request to get access token with new OAuth code.

    Args:
      code: Oura code to fetch access token.

    Returns:
      Response from requesting new token.
      Most likely: Access and refresh token data in a dictionary.
    """
    request_auth = requests.auth.HTTPBasicAuth(self._client_id,
                                               self._client_secret)

    base_url = self._sensor._hass.config.api.base_url
    callback_url = f'{base_url}{views.AUTH_CALLBACK_PATH}'
    request_data = {
        'grant_type': 'authorization_code',
        'code': code,
        'redirect_uri': callback_url,
    }

    request_headers = {
        'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
    }

    request_url = self._get_api_endpoint(OuraEndpoints.TOKEN)

    response = requests.post(
        request_url, auth=request_auth, data=request_data,
        headers=request_headers)

    return response.json()

Once this call is made, we should have received the access token and refresh tokens. The next step is to store them and update the API class properties.

  def _store_access_token_data(self, access_token_data):
    """Validates and stores access token data into file.

    Args:
      access_token_data: Dictionary containing access token and refresh token.
    """
    if 'access_token' not in access_token_data:
      logging.error('Oura API was unable to retrieve new API token.')
      return

    if 'refresh_token' not in access_token_data:
      if self._refresh_token:
        access_token_data['refresh_token'] = self._refresh_token
      else:
        logging.error(
            'Refresh token not available. Oura API will become unauthorized.')
        return

    self._access_token = access_token_data['access_token']
    self._refresh_token = access_token_data['refresh_token']

At this stage, the OAuth is completed, the update sensor will continue and will start showing data from the API.


At a later update, unrelated to this one, this access token will be expired. In that case, we need to use the refresh_token and exchange it for a new access token.

The expiration can be validated during the call stages when received an authorized request.

def get_sleep_data(self, start_date, end_date=None):
    """Fetches data for a sleep OuraEndpoint and date.

    Args:
      start_date: Day for which to fetch data(YYYY-MM-DD).
      end_date: Last day for which to retrieve data(YYYY-MM-DD).
        If same as start_date, leave empty.

    Returns:
      Dictionary containing Oura sleep data.
      None if the access token was not found or authorized.
    """
    if not self._access_token:
      self._get_access_token_data_from_file()

    # If after fetching the token, it is still not available, the update should
    # not go through. This is most likely at the OAuth set up stage and may
    # require input from the user.
    if not self._access_token:
      return None

    retries = 0
    while retries < _MAX_API_RETRIES:
      api_url = self._get_api_endpoint(OuraEndpoints.SLEEP,
                                       start_date=start_date)

      response = requests.get(api_url)
      response_data = response.json()

      if not response_data:
        retries += 1
        continue

      if response_data.get('message') == 'Unauthorized':
        retries += 1
        self._refresh_access_token()
        continue

      return response_data

    return None

If the response fails with “Unauthorized”, it most likely means that the token expired. (Do note that this might be different on your API). If that’s the case, we trigger the process to refresh the access token.

This basically consists of exchanging the refresh token for a new access token.

  def _refresh_access_token(self):
    """Gets a new access token using refresh token."""
    access_token_data = self._request_access_token_with_refresh_token()
    self._store_access_token_data(access_token_data)

  def _request_access_token_with_refresh_token(self):
    """Sends a request to get access token with the existing refresh token.

    Returns:
      Response from requesting new token.
      Most likely: Access and refresh token data in a dictionary.
    """
    request_auth = requests.auth.HTTPBasicAuth(self._client_id,
                                               self._client_secret)
    request_data = {
        'grant_type': 'refresh_token',
        'refresh_token': self._refresh_token,
    }
    request_url = self._get_api_endpoint(OuraEndpoints.TOKEN)

    response = requests.post(request_url, auth=request_auth, data=request_data)
    return response.json()

Once the new token is retrieve, we call _store_access_token_data to store to the temp file.

If someone is interested, I have just published the full code on Github and will update now the sensor page on the community.

2 Likes