Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle facebook limited login #113

Merged
merged 8 commits into from
Jan 8, 2025
146 changes: 101 additions & 45 deletions src/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
import cachecontrol # type: ignore
import jwt

from cryptography.hazmat.primitives import serialization
from jwt.exceptions import InvalidTokenError

from flask.wrappers import Request
from flask.globals import current_app

Expand All @@ -40,6 +43,7 @@
DEFAULT_LOCALE,
FACEBOOK_APP_SECRET,
FACEBOOK_APP_ID,
FACEBOOK_NONCE,
APPLE_CLIENT_ID,
DEV_SERVER,
FlaskConfig,
Expand All @@ -61,6 +65,9 @@
"https://graph.facebook.com/debug_token?input_token={0}&access_token={1}|{2}"
)

# Facebook public key endpoint
FACEBOOK_JWT_ENDPOINT = "https://www.facebook.com/.well-known/oauth/openid/jwks/"

# Apple public key and JWT stuff
APPLE_TOKEN_VALIDATION_URL = "https://appleid.apple.com/auth/token"
APPLE_JWKS_URL = "https://appleid.apple.com/auth/keys"
Expand Down Expand Up @@ -107,6 +114,16 @@ def rq_get(request: Request, key: str) -> str:
return j.get(key, "")
return ""

def get_facebook_public_key():
vthorsteinsson marked this conversation as resolved.
Show resolved Hide resolved
vthorsteinsson marked this conversation as resolved.
Show resolved Hide resolved
# Get the public key from Facebook's JWKS endpoint
response = requests.get(FACEBOOK_JWT_ENDPOINT)
vthorsteinsson marked this conversation as resolved.
Show resolved Hide resolved
jwks = response.json()

# Extract the public key in PEM format
key_data = jwks['keys'][0]
public_key_pem = jwt.algorithms.RSAAlgorithm.from_jwk(key_data)

return public_key_pem

def oauth2callback(request: Request) -> ResponseType:
# Note that HTTP GETs to the /oauth2callback URL are handled in web.py,
Expand Down Expand Up @@ -321,57 +338,96 @@ def oauth_fb(request: Request) -> ResponseType:
)

token = user.get("token", "")
# Validate the Facebook token
if not token or len(token) > 1024 or not token.isalnum():
return jsonify({"status": "invalid", "msg": "Invalid Facebook token"}), 401
r = requests.get(
FACEBOOK_TOKEN_VALIDATION_URL.format(
token, facebook_app_id, facebook_app_secret
)
)
if r.status_code != 200:
# Error from the Facebook API: communicate it back to the client
msg = ""

# Verify if this is a limited login (iOS only)
is_limited_login = rq.get("isLimitedLogin", False)
vthorsteinsson marked this conversation as resolved.
Show resolved Hide resolved
if is_limited_login:
# Validate Limited Login token
try:
msg = cast(Any, r).json()["error"]["message"]
msg = f": {msg}"
except (KeyError, ValueError):
pass
return (
jsonify(
{
"status": "invalid",
"msg": f"Unable to verify Facebook token{msg}", # Lack of space intentional
}
),
401,
# Get the public key from Facebook's JWKS endpoint
public_key = get_facebook_public_key()
decoded_token = jwt.decode(
token,
public_key,
algorithms=['RS256'],
audience=facebook_app_id,
options={"verify_exp": True}
)
if decoded_token.get('nonce') != FACEBOOK_NONCE:
return (
jsonify(
{"status": "invalid", "msg": "Invalid Facebook nonce token",}
),
401,
)
account = decoded_token.get('sub', '')
except jwt.ExpiredSignatureError:
return (
jsonify(
{"status": "invalid", "msg": "Token has expired",}
),
401,
)
except jwt.InvalidTokenError:
return (
jsonify(
{"status": "invalid", "msg": "Invalid Facebook expired",}
vthorsteinsson marked this conversation as resolved.
Show resolved Hide resolved
),
401,
)
else:
# Validate the Facebook token
if not token or len(token) > 1024 or not token.isalnum():
return jsonify({"status": "invalid", "msg": "Invalid Facebook token"}), 401
# Validate regular Facebook token
r = requests.get(
FACEBOOK_TOKEN_VALIDATION_URL.format(
token, facebook_app_id, facebook_app_secret
)
)
if r.status_code != 200:
# Error from the Facebook API: communicate it back to the client
msg = ""
try:
msg = cast(Any, r).json()["error"]["message"]
msg = f": {msg}"
except (KeyError, ValueError):
pass
return (
jsonify(
{
"status": "invalid",
"msg": f"Unable to verify Facebook token{msg}", # Lack of space intentional
}
),
401,
)
response: Dict[str, Any] = cast(Any, r).json()
if not response or not (rd := response.get("data")):
return (
jsonify(
{"status": "invalid", "msg": "Invalid format of Facebook token data"}
),
401,
)
if (
facebook_app_id != rd.get("app_id")
or "USER" != rd.get("type")
or not rd.get("is_valid")
):
return (
jsonify({"status": "invalid", "msg": "Facebook token data mismatch"}),
401,
)
if account != rd.get("user_id"):
return (
jsonify({"status": "invalid", "msg": "Wrong user id in Facebook token"}),
401,
)
# So far, so good: double check that token data are as expected
name = user.get("full_name", "")
image = user.get("image", "")
email = user.get("email", "").lower()
response: Dict[str, Any] = cast(Any, r).json()
if not response or not (rd := response.get("data")):
return (
jsonify(
{"status": "invalid", "msg": "Invalid format of Facebook token data"}
),
401,
)
if (
facebook_app_id != rd.get("app_id")
or "USER" != rd.get("type")
or not rd.get("is_valid")
):
return (
jsonify({"status": "invalid", "msg": "Facebook token data mismatch"}),
401,
)
if account != rd.get("user_id"):
return (
jsonify({"status": "invalid", "msg": "Wrong user id in Facebook token"}),
401,
)
# Make sure that Facebook account ids are different from Google/OAuth ones
# by prefixing them with 'fb:'
account = "fb:" + account
Expand Down
4 changes: 4 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ class FlaskConfig(TypedDict):
), f"FACEBOOK_APP_SECRET not set correctly in {CLIENT_SECRET_ID}"
assert FACEBOOK_APP_ID, f"FACEBOOK_APP_ID not set correctly in {CLIENT_SECRET_ID}"

# Facebook nonce for limited login verification
FACEBOOK_NONCE: str = j.get("FACEBOOK_NONCE", "")
assert FACEBOOK_NONCE, f"FACEBOOK_NONCE not set correctly in {CLIENT_SECRET_ID}"

# Firebase configuration
FIREBASE_API_KEY: str = j.get("FIREBASE_API_KEY", "")
FIREBASE_SENDER_ID: str = j.get("FIREBASE_SENDER_ID", "")
Expand Down