Cloud Functions Store Oauth2 Credentials

October 19, 2019

There are mainly 3 options for Credentials storage on could function

  • Cloud Storage ($0.026 per GB, network free within same location, $0.05 per 10,000 operation)
  • Firestore ($0.18 per GB, network free within same location, $0.06/$0.18 per 100,000 document)
  • Datastore

I chose Firestore, with Cloud Functions /tmp directory as local cache.

import logging
import os
import datetime

log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)

class FirestoreStorage:
    def __init__(self, doc_ref, content_field, created_field, modified_field, file_cache=None):
        self.doc_ref = doc_ref
        self.content_field = content_field
        self.created_field = created_field
        self.modified_field = modified_field
        if file_cache:
            self.file_cache = os.path.join(file_cache, doc_ref.id)
        else:
            self.file_cache = None


    def get(self):
        if self.file_cache:
            if os.path.exists(self.file_cache):
                log.debug(f"get from file: {self.file_cache}")
                with open(self.file_cache, 'rb') as f:
                    return f.read()

        log.debug(f"get from firestore: {self.doc_ref.path}")
        doc = self.doc_ref.get()
        if not doc.exists:
            log.debug(f"firestore object does not exist")
            return None
        return doc.get(self.content_field)

    def update(self, content):
        if self.file_cache:
            os.makedirs(os.path.dirname(self.file_cache), exist_ok=True)
            with open(self.file_cache, 'wb') as f:
                log.debug(f"write to file: {self.file_cache}")
                f.write(content)

        data = {
            self.modified_field: datetime.datetime.now(),
            self.content_field: content
        }
        self.doc_ref.update(data)

    def create(self, content):
        if self.file_cache:
            os.makedirs(os.path.dirname(self.file_cache), exist_ok=True)
            with open(self.file_cache, 'wb') as f:
                log.debug(f"write to file: {self.file_cache}")
                f.write(content)

        data = {
            self.created_field: datetime.datetime.now(),
            self.content_field: content
        }
        self.doc_ref.set(data)

    def delete(self):
        if self.file_cache:
            if os.path.exists(self.file_cache):
                os.remove(self.file_cache)

        self.doc_ref.delete()

Usage

import pickle
import base64

from googleapiclient.discovery import build
from google_auth_oauthlib.flow import Flow, InstalledAppFlow
from google.auth.transport.requests import Request
from firebase_admin import firestore

def get_oauth2_credentials(uid, auth_code, scopes, secret_json_file):
    db = firestore.client()

    # prevent invalid chars in auth_code, convert to base64
    doc_id = base64.urlsafe_b64encode(auth_code.encode('utf-8')).decode('utf-8')

    doc_ref = db.collection('user').document('uid').collection('oauth2_credential').document(doc_id)

    storage = FirestoreStorage(
        doc_ref=doc_ref,
        content_field='pickle',
        created_field='created',
        modified_field='modified',
        file_cache='/tmp/oauth2')

    creds = storage.get()
    if creds:
        creds = pickle.loads(creds)
        if not creds.valid:
            if creds.expired and creds.refresh_token:
                creds.refresh(Request())
                storage.update(pickle.dumps(creds))
            else:
                storage.delete()
                json_abort(400, message="Credentials expired/invalid and could not refresh")
    else:
        flow = Flow.from_client_secrets_file(
            secret_json_file,
            scopes=scopes)
        # if same auth_code is used again
        # oauthlib.oauth2.rfc6749.errors.InvalidGrantError: (invalid_grant) Bad Request
        flow.fetch_token(code=auth_code)
        creds = flow.credentials
        storage.create(pickle.dumps(creds))

    return creds

NOTE: Refer Setup/Access Firestore on Cloud Functions (Python)

NOTE: Refer json_abort

from flask import jsonify

from googleapiclient.discovery import build

@firebase_auth_required
def google_photos_albums_list(request, decoded_token):
    uid = decoded_token['uid']

    if not request.is_json:
        json_abort(400, message="Invalid request (json)")

    data = request.json.get('data')
    if not data:
        json_abort(400, message="Invalid request (data)")

    scopes = [
        'https://www.googleapis.com/auth/userinfo.profile',
        'openid',
        'https://www.googleapis.com/auth/photoslibrary.readonly'
        ]

    credentials = get_oauth2_credentials(
        uid=uid,
        auth_code=data['auth_code'],
        scopes=scopes,
        secret_json_file='keys/oauth2-secret.json'
        )

    service = build('photoslibrary', 'v1', credentials=creds, cache_discovery=False)

    items = results.get('albums', [])

    '''
    if not items:
        log.info('No items')
    else:
        for item in items:
            log.info(item['title'])
            # log.info(json.dumps(item, indent=2))
    '''

    data = {
        'albums': items,
        'next_page_token': results.get('nextPageToken', None)
    }

    return jsonify({
            'data': data
        })

NOTE: Refer firebase_auth_required

NOTE: Refer Android Access Google Photos API via Python REST

This work is licensed under a
Creative Commons Attribution-NonCommercial 4.0 International License.