Skip to content

Commit

Permalink
Add Firebase Auth sample (#491)
Browse files Browse the repository at this point in the history
  • Loading branch information
tristanvanech authored and Jon Wayne Parrott committed Sep 7, 2016
1 parent ee148dc commit aca4aa2
Show file tree
Hide file tree
Showing 15 changed files with 895 additions and 0 deletions.
60 changes: 60 additions & 0 deletions appengine/standard/firebase/firenotes/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Firenotes: Firebase Authentication on Google App Engine

A simple note-taking application that stores users' notes in their own personal
notebooks separated by a unique user ID generated by Firebase. Uses Firebase
Authentication, Google App Engine, and Google Cloud Datastore.

You'll need to have [Python 2.7](https://www.python.org/), the
[App Engine SDK](https://cloud.google.com/appengine/downloads#Google_App_Engine_SDK_for_Python),
and the [Google Cloud SDK](https://cloud.google.com/sdk/?hl=en)
installed and initialized to an App Engine project before running the code in
this sample.

## Setup

1. Clone this repo:

git clone https://github.com/GoogleCloudPlatform/python-docs-samples/tree/master/appengine/standard/firebase/auth/firenotes

1. Within a virtualenv, install the dependencies to the backend service:

pip install -r requirements.txt -t lib
pip install pycrypto

Although the pycrypto library is built in to the App Engine standard
environment, it will not be bundled until deployment since it is
platform-dependent. Thus, the app.yaml file includes the bundled version of
pycrypto at runtime, but you still need to install it manually to run the
application on the App Engine local development server.

1. [Add Firebase to your app.](https://firebase.google.com/docs/web/setup#add_firebase_to_your_app)
1. Add your Firebase Project ID to the backend’s `app.yaml` file as an
environment variable.
1. Select which providers you want to enable. Delete the providers from
`main.js` that you do no want to offer. Enable the providers you chose to keep
in the Firebase console under **Auth** > **SIGN-IN METHOD** >
**Sign-in providers**.
1. In the Firebase console, under **OAuth redirect domains**, click
**ADD DOMAIN** and enter the domain of your app on App Engine:
[PROJECT_ID].appspot.com. Do not include "http://" before the domain name.

## Run Locally
1. Add the backend host URL to `main.js`: http://localhost:8081.
1. Navigate to the root directory of the application and start the development
server with the following command:

dev_appserver.py frontend/app.yaml backend/app.yaml

1. Visit [http://locahost:8080/](http://locahost:8080/) in a web browser.

## Deploy
1. Change the backend host URL in `main.js` to
https://backend-dot-[PROJECT_ID].appspot.com.
1. Deploy the application using the Cloud SDK command-line interface:

gcloud app deploy backend/index.yaml frontend/app.yaml backend/app.yaml

The Cloud Datastore indexes can take a while to update, so the application
might not be fully functional immediately after deployment.

1. View the application live at https://[PROJECT_ID].appspot.com.
1 change: 1 addition & 0 deletions appengine/standard/firebase/firenotes/backend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
lib
18 changes: 18 additions & 0 deletions appengine/standard/firebase/firenotes/backend/app.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
runtime: python27
api_version: 1
threadsafe: true
service: backend

handlers:
- url: /.*
script: main.app

libraries:
- name: ssl
version: 2.7.11
- name: pycrypto
version: 2.6

env_variables:
# Replace with your Firebase project ID.
FIREBASE_PROJECT_ID: '<PROJECT_ID>'
18 changes: 18 additions & 0 deletions appengine/standard/firebase/firenotes/backend/appengine_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Copyright 2016 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from google.appengine.ext import vendor

# Add any libraries installed in the "lib" folder.
vendor.add('lib')
121 changes: 121 additions & 0 deletions appengine/standard/firebase/firenotes/backend/firebase_helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Copyright 2016 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json
import logging
import os
import ssl

from Crypto.Util import asn1
from google.appengine.api import urlfetch
from google.appengine.api import urlfetch_errors
import jwt
from jwt.contrib.algorithms.pycrypto import RSAAlgorithm
import jwt.exceptions


# For App Engine, pyjwt needs to use PyCrypto instead of Cryptography.
jwt.register_algorithm('RS256', RSAAlgorithm(RSAAlgorithm.SHA256))

# [START fetch_certificates]
# This URL contains a list of active certificates used to sign Firebase
# auth tokens.
FIREBASE_CERTIFICATES_URL = (
'https://www.googleapis.com/robot/v1/metadata/x509/'
'securetoken@system.gserviceaccount.com')


# [START get_firebase_certificates]
def get_firebase_certificates():
"""Fetches the current Firebase certificates.
Note: in a production application, you should cache this for at least
an hour.
"""
try:
result = urlfetch.Fetch(
FIREBASE_CERTIFICATES_URL,
validate_certificate=True)
data = result.content
except urlfetch_errors.Error:
logging.error('Error while fetching Firebase certificates.')
raise

certificates = json.loads(data)

return certificates
# [END get_firebase_certificates]
# [END fetch_certificates]


# [START extract_public_key_from_certificate]
def extract_public_key_from_certificate(x509_certificate):
"""Extracts the PEM public key from an x509 certificate."""
der_certificate_string = ssl.PEM_cert_to_DER_cert(x509_certificate)

# Extract subjectPublicKeyInfo field from X.509 certificate (see RFC3280)
der_certificate = asn1.DerSequence()
der_certificate.decode(der_certificate_string)
tbs_certification = asn1.DerSequence() # To Be Signed certificate
tbs_certification.decode(der_certificate[0])

subject_public_key_info = tbs_certification[6]

return subject_public_key_info
# [EMD extract_public_key_from_certificate]


# [START verify_auth_token]
def verify_auth_token(request):
"""Verifies the JWT auth token in the request.
If no token is found or if the token is invalid, returns None.
Otherwise, it returns a dictionary containing the JWT claims.
"""
if 'Authorization' not in request.headers:
return None

# Auth header is in format 'Bearer {jwt}'.
request_jwt = request.headers['Authorization'].split(' ').pop()

# Determine which certificate was used to sign the JWT.
header = jwt.get_unverified_header(request_jwt)
kid = header['kid']

certificates = get_firebase_certificates()

try:
certificate = certificates[kid]
except KeyError:
logging.warning('JWT signed with unkown kid {}'.format(header['kid']))
return None

# Get the public key from the certificate. This is used to verify the
# JWT signature.
public_key = extract_public_key_from_certificate(certificate)

# [START decrypt_token]
try:
claims = jwt.decode(
request_jwt,
public_key,
algorithms=['RS256'],
audience=os.environ['FIREBASE_PROJECT_ID'])
except jwt.exceptions.InvalidTokenError as e:
logging.warning('JWT verification failed: {}'.format(e))
return None
# [END decrypt_token]

return claims
# [END verify_auth_token]
176 changes: 176 additions & 0 deletions appengine/standard/firebase/firenotes/backend/firebase_helper_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# Copyright 2016 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import datetime
import os
import time

# Remove any existing pyjwt handlers, as firebase_helper will register
# its own.
try:
import jwt
jwt.unregister_algorithm('RS256')
except KeyError:
pass

import mock
import pytest

import firebase_helper


def test_get_firebase_certificates(testbed):
certs = firebase_helper.get_firebase_certificates()
assert certs
assert len(certs.keys())


@pytest.fixture
def test_certificate():
from cryptography import utils
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from cryptography.x509.oid import NameOID

one_day = datetime.timedelta(1, 0, 0)
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend())
public_key = private_key.public_key()
builder = x509.CertificateBuilder()

builder = builder.subject_name(x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, u'example.com'),
]))
builder = builder.issuer_name(x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, u'example.com'),
]))
builder = builder.not_valid_before(datetime.datetime.today() - one_day)
builder = builder.not_valid_after(datetime.datetime.today() + one_day)
builder = builder.serial_number(
utils.int_from_bytes(os.urandom(20), "big") >> 1)
builder = builder.public_key(public_key)

builder = builder.add_extension(
x509.BasicConstraints(ca=False, path_length=None), critical=True)

certificate = builder.sign(
private_key=private_key, algorithm=hashes.SHA256(),
backend=default_backend())

certificate_pem = certificate.public_bytes(serialization.Encoding.PEM)
public_key_bytes = certificate.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo)
private_key_bytes = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption())

yield certificate, certificate_pem, public_key_bytes, private_key_bytes


def test_extract_public_key_from_certificate(test_certificate):
_, certificate_pem, public_key_bytes, _ = test_certificate
public_key = firebase_helper.extract_public_key_from_certificate(
certificate_pem)
assert public_key == public_key_bytes


def make_jwt(private_key_bytes, claims=None, headers=None):
jwt_claims = {
'iss': 'http://example.com',
'aud': 'test_audience',
'user_id': '123',
'sub': '123',
'iat': int(time.time()),
'exp': int(time.time()) + 60,
'email': 'user@example.com'
}

jwt_claims.update(claims if claims else {})
if not headers:
headers = {}

return jwt.encode(
jwt_claims, private_key_bytes, algorithm='RS256',
headers=headers)


def test_verify_auth_token(test_certificate, monkeypatch):
_, certificate_pem, _, private_key_bytes = test_certificate

# The Firebase project ID is used as the JWT audience.
monkeypatch.setenv('FIREBASE_PROJECT_ID', 'test_audience')

# Generate a jwt to include in the request.
jwt = make_jwt(private_key_bytes, headers={'kid': '1'})

# Make a mock request
request = mock.Mock()
request.headers = {'Authorization': 'Bearer {}'.format(jwt)}

get_cert_patch = mock.patch('firebase_helper.get_firebase_certificates')
with get_cert_patch as get_cert_mock:
# Make get_firebase_certificates return our test certificate.
get_cert_mock.return_value = {'1': certificate_pem}
claims = firebase_helper.verify_auth_token(request)

assert claims['user_id'] == '123'


def test_verify_auth_token_no_auth_header():
request = mock.Mock()
request.headers = {}
assert firebase_helper.verify_auth_token(request) is None


def test_verify_auth_token_invalid_key_id(test_certificate):
_, _, _, private_key_bytes = test_certificate
jwt = make_jwt(private_key_bytes, headers={'kid': 'invalid'})
request = mock.Mock()
request.headers = {'Authorization': 'Bearer {}'.format(jwt)}

get_cert_patch = mock.patch('firebase_helper.get_firebase_certificates')
with get_cert_patch as get_cert_mock:
# Make get_firebase_certificates return no certificates
get_cert_mock.return_value = {}
assert firebase_helper.verify_auth_token(request) is None


def test_verify_auth_token_expired(test_certificate, monkeypatch):
_, certificate_pem, _, private_key_bytes = test_certificate

# The Firebase project ID is used as the JWT audience.
monkeypatch.setenv('FIREBASE_PROJECT_ID', 'test_audience')

# Generate a jwt to include in the request.
jwt = make_jwt(
private_key_bytes,
claims={'exp': int(time.time()) - 60},
headers={'kid': '1'})

# Make a mock request
request = mock.Mock()
request.headers = {'Authorization': 'Bearer {}'.format(jwt)}

get_cert_patch = mock.patch('firebase_helper.get_firebase_certificates')
with get_cert_patch as get_cert_mock:
# Make get_firebase_certificates return our test certificate.
get_cert_mock.return_value = {'1': certificate_pem}
assert firebase_helper.verify_auth_token(request) is None
Loading

0 comments on commit aca4aa2

Please sign in to comment.