diff --git a/pyproject.toml b/pyproject.toml index 54a8e0a..a476cfb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "psycopg2-binary>=2.9.9,<3.0.0", "python-magic>=0.4.27,<0.5.0", "google-auth>=2.29.0,<3.0.0", + "pyjwt[crypto]>=2.8.0,<3.0.0", "alembic>=1.13.2,<2.0.0", "argon2-cffi>=23.1.0,<24.0.0", "typer>=0.16.0,<0.17.0", diff --git a/settings_example.toml b/settings_example.toml index 62cf09e..d232bf7 100644 --- a/settings_example.toml +++ b/settings_example.toml @@ -111,6 +111,30 @@ public_access = false # Example: https://openrelik.example.com/auth/oidc # redirect_uri = "" +[auth.iap] +# Google Cloud Identity-Aware Proxy (IAP) authentication. +# For deployments where IAP fronts the server, this validates the signed +# X-Goog-IAP-JWT-Assertion header that IAP adds to every proxied request: +# https://cloud.google.com/iap/docs/signed-headers-howto +# +# The expected audience for the assertion. For a backend service this is +# /projects/PROJECT_NUMBER/global/backendServices/SERVICE_ID and it is shown +# in the IAP console under "Signed Header JWT Audience". +# audience = "" + +# Allow only these users (email address) to access the server. +# allowlist = [""] + +# Allow anyone with a valid IAP assertion to access the server. Unlike the +# google/oidc public_access options this does not expose the server publicly: +# only identities that IAP itself has authenticated and authorized can carry a +# valid assertion, so access control can be fully delegated to IAP policy. +# public_access = false + +# Override the URL for IAP's public verification keys. Only useful for +# testing against locally minted assertions; leave unset in production. +# certs_url = "" + [ui] # data_types that will be rendered using unescaped HTML in a sandboxed iframe in the # frontend UI. diff --git a/src/auth/iap.py b/src/auth/iap.py new file mode 100644 index 0000000..5c0fae0 --- /dev/null +++ b/src/auth/iap.py @@ -0,0 +1,168 @@ +# Copyright 2026 Google LLC +# +# 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 +# +# https://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 uuid +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Header, HTTPException +from google.auth.transport import requests +from google.oauth2 import id_token +from jwt.exceptions import PyJWTError +from sqlalchemy.orm import Session +from starlette.responses import RedirectResponse + +from api.v1 import schemas +from config import config +from datastores.sql.crud.user import create_user_in_db, get_user_by_email_from_db +from datastores.sql.database import get_db_connection + +from .common import UI_SERVER_URL, create_jwt_token, generate_csrf_token + +router = APIRouter() + +# Issuer and signing keys for IAP assertions, see +# https://cloud.google.com/iap/docs/signed-headers-howto +IAP_ISSUER = "https://cloud.google.com/iap" +DEFAULT_CERTS_URL = "https://www.gstatic.com/iap/verify/public_key-jwk" + +IAP_AUDIENCE = config["auth"]["iap"]["audience"] +IAP_CERTS_URL = config["auth"]["iap"].get("certs_url") or DEFAULT_CERTS_URL +IAP_ALLOW_LIST = config["auth"]["iap"].get("allowlist", []) +IAP_PUBLIC_ACCESS = config["auth"]["iap"].get("public_access", False) +REFRESH_TOKEN_EXPIRE_MINUTES = config["auth"]["jwt_cookie_refresh_expire_minutes"] +ACCESS_TOKEN_EXPIRE_MINUTES = config["auth"]["jwt_cookie_access_expire_minutes"] + + +def _validate_iap_assertion(assertion: str) -> dict[str, Any]: + """Validates a Google Cloud IAP signed-header assertion. + + Verifies the JWT signature against IAP's public keys, and checks the + expiry, audience and issuer claims. The raw header value must never be + trusted without this verification. + + Args: + assertion (str): The JWT from the X-Goog-IAP-JWT-Assertion header. + + Returns: + dict: The verified claims. + + Raises: + HTTPException: If the assertion is invalid. + """ + try: + claims = id_token.verify_token( + assertion, + requests.Request(), + audience=IAP_AUDIENCE, + certs_url=IAP_CERTS_URL, + ) + # google-auth delegates JWK-set verification to PyJWT and lets its + # exceptions (malformed token, bad signature, audience mismatch, + # expiry) propagate alongside its own ValueError. + except (ValueError, PyJWTError): + raise HTTPException(status_code=401, detail="Unauthorized. Invalid IAP assertion.") + + if claims.get("iss") != IAP_ISSUER: + raise HTTPException(status_code=401, detail="Unauthorized. Invalid issuer.") + + return dict(claims) + + +def _validate_user_info(user_info: dict[str, Any]) -> None: + """Validates that a user is allowed to access the server. + + Args: + user_info (dict): The verified claims from the IAP assertion. + + Raises: + HTTPException: If the user is not allowed to access the server. + """ + if IAP_PUBLIC_ACCESS: + return # Authorization is delegated to the proxy. + + if user_info.get("email", "") not in IAP_ALLOW_LIST: + raise HTTPException(status_code=401, detail="Unauthorized. Not in allowlist.") + + +@router.get("/login/iap") +async def login( + x_goog_iap_jwt_assertion: Annotated[str | None, Header()] = None, + db: Session = Depends(get_db_connection), +) -> RedirectResponse: + """Authenticates a user with a Google Cloud IAP signed-header assertion. + + IAP adds a signed assertion header to every request it proxies, so there + is no redirect dance: validating the header is the whole login flow. + + Args: + x_goog_iap_jwt_assertion (str | None): The IAP assertion header. + db (Session): The database session object. + + Returns: + RedirectResponse: A redirect response to the UI server with JWT tokens + set as cookies. + + Raises: + HTTPException: If the assertion is missing or invalid, or the user is + not authorized. + """ + if not x_goog_iap_jwt_assertion: + raise HTTPException( + status_code=401, + detail="Unauthorized. Missing X-Goog-IAP-JWT-Assertion header.", + ) + + user_info = _validate_iap_assertion(x_goog_iap_jwt_assertion) + + user_email = user_info.get("email", "") + if not user_email: + raise HTTPException( + status_code=401, detail="Unauthorized. Assertion has no email claim." + ) + + _validate_user_info(user_info) + + db_user = get_user_by_email_from_db(db, email=user_email) + if not db_user: + # IAP assertions don't carry profile claims (name, picture), so the + # email address doubles as the display name. + new_user = schemas.UserCreate( + display_name=user_email, + username=user_email, + email=user_email, + auth_method="iap", + profile_picture_url="", + uuid=uuid.uuid4(), + ) + db_user = create_user_in_db(db, new_user) + + refresh_token = create_jwt_token( + audience="browser-client", + expire_minutes=REFRESH_TOKEN_EXPIRE_MINUTES, + subject=db_user.uuid.hex, + token_type="refresh", + ) + + access_token = create_jwt_token( + audience="browser-client", + expire_minutes=ACCESS_TOKEN_EXPIRE_MINUTES, + subject=db_user.uuid.hex, + token_type="access", + ) + + response = RedirectResponse(url=UI_SERVER_URL) + response.set_cookie(key="refresh_token", value=refresh_token, httponly=True) + response.set_cookie(key="access_token", value=access_token, httponly=True) + response.set_cookie(key="csrf_token", value=generate_csrf_token(), httponly=True) + + return response diff --git a/src/main.py b/src/main.py index 541f505..7fe7bfc 100644 --- a/src/main.py +++ b/src/main.py @@ -40,6 +40,9 @@ if config.get("auth", {}).get("oidc"): from auth import oidc as oidc_auth +if config.get("auth", {}).get("iap"): + from auth import iap as iap_auth + from datastores.sql.crud.group import ( add_user_to_group, create_group_in_db, @@ -124,6 +127,8 @@ async def lifespan(app: FastAPI): app.include_router(google_auth.router) if config.get("auth", {}).get("oidc"): app.include_router(oidc_auth.router) +if config.get("auth", {}).get("iap"): + app.include_router(iap_auth.router) app.include_router(healthz_router) # Routes diff --git a/src/tests/auth/iap_test.py b/src/tests/auth/iap_test.py new file mode 100644 index 0000000..06c8652 --- /dev/null +++ b/src/tests/auth/iap_test.py @@ -0,0 +1,222 @@ +# Copyright 2026 Google LLC +# +# 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 +# +# https://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 pytest +from fastapi import HTTPException +from starlette.responses import RedirectResponse + +try: + from config import config + + if "auth" not in config: + config["auth"] = {} + config["auth"]["iap"] = { + "audience": "/projects/123/global/backendServices/456", + "allowlist": [], + "public_access": False, + } + config["auth"]["jwt_cookie_refresh_expire_minutes"] = 60 + config["auth"]["jwt_cookie_access_expire_minutes"] = 15 +except ImportError: + pass + +import auth.iap as iap_auth + + +def test_validate_user_info_public_access(mocker): + """Test public access allowed.""" + mocker.patch("auth.iap.IAP_PUBLIC_ACCESS", True) + mocker.patch("auth.iap.IAP_ALLOW_LIST", []) + user_info = {"email": "user@any.com"} + # Should not raise any exception + iap_auth._validate_user_info(user_info) + + +def test_validate_user_info_allowlist_ok(mocker): + """Test user in allowlist.""" + mocker.patch("auth.iap.IAP_PUBLIC_ACCESS", False) + mocker.patch("auth.iap.IAP_ALLOW_LIST", ["user@allowed.com"]) + user_info = {"email": "user@allowed.com"} + # Should not raise any exception + iap_auth._validate_user_info(user_info) + + +def test_validate_user_info_allowlist_fail(mocker): + """Test user not in allowlist.""" + mocker.patch("auth.iap.IAP_PUBLIC_ACCESS", False) + mocker.patch("auth.iap.IAP_ALLOW_LIST", ["user@allowed.com"]) + user_info = {"email": "user@notallowed.com"} + with pytest.raises(HTTPException) as excinfo: + iap_auth._validate_user_info(user_info) + assert excinfo.value.status_code == 401 + assert excinfo.value.detail == "Unauthorized. Not in allowlist." + + +def test_validate_iap_assertion_success(mocker): + """Test assertion validation with valid claims.""" + mock_verify_token = mocker.patch("auth.iap.id_token.verify_token") + mock_verify_token.return_value = { + "iss": "https://cloud.google.com/iap", + "email": "user@example.com", + } + + claims = iap_auth._validate_iap_assertion("valid-assertion") + + assert claims["email"] == "user@example.com" + mock_verify_token.assert_called_once() + _, kwargs = mock_verify_token.call_args + assert kwargs["audience"] == iap_auth.IAP_AUDIENCE + assert kwargs["certs_url"] == iap_auth.IAP_CERTS_URL + + +def test_validate_iap_assertion_invalid_token(mocker): + """Test assertion validation with an invalid signature/audience/expiry.""" + mock_verify_token = mocker.patch("auth.iap.id_token.verify_token") + mock_verify_token.side_effect = ValueError("invalid token") + + with pytest.raises(HTTPException) as excinfo: + iap_auth._validate_iap_assertion("invalid-assertion") + + assert excinfo.value.status_code == 401 + assert excinfo.value.detail == "Unauthorized. Invalid IAP assertion." + + +def test_validate_iap_assertion_pyjwt_error(mocker): + """Test assertion validation when PyJWT raises (malformed token, bad audience).""" + from jwt.exceptions import InvalidAudienceError + + mock_verify_token = mocker.patch("auth.iap.id_token.verify_token") + mock_verify_token.side_effect = InvalidAudienceError("Audience doesn't match") + + with pytest.raises(HTTPException) as excinfo: + iap_auth._validate_iap_assertion("assertion-with-wrong-audience") + + assert excinfo.value.status_code == 401 + assert excinfo.value.detail == "Unauthorized. Invalid IAP assertion." + + +def test_validate_iap_assertion_wrong_issuer(mocker): + """Test assertion validation with an unexpected issuer.""" + mock_verify_token = mocker.patch("auth.iap.id_token.verify_token") + mock_verify_token.return_value = { + "iss": "https://accounts.google.com", + "email": "user@example.com", + } + + with pytest.raises(HTTPException) as excinfo: + iap_auth._validate_iap_assertion("assertion-with-wrong-issuer") + + assert excinfo.value.status_code == 401 + assert excinfo.value.detail == "Unauthorized. Invalid issuer." + + +@pytest.mark.asyncio +async def test_login_missing_header(mocker): + """Test login without an assertion header.""" + mock_db = mocker.MagicMock() + + with pytest.raises(HTTPException) as excinfo: + await iap_auth.login(x_goog_iap_jwt_assertion=None, db=mock_db) + + assert excinfo.value.status_code == 401 + assert "Missing X-Goog-IAP-JWT-Assertion" in excinfo.value.detail + + +@pytest.mark.asyncio +async def test_login_missing_email_claim(mocker): + """Test login with an assertion that has no email claim.""" + mock_validate_assertion = mocker.patch("auth.iap._validate_iap_assertion") + mock_validate_assertion.return_value = {"iss": "https://cloud.google.com/iap"} + mock_db = mocker.MagicMock() + + with pytest.raises(HTTPException) as excinfo: + await iap_auth.login(x_goog_iap_jwt_assertion="assertion", db=mock_db) + + assert excinfo.value.status_code == 401 + assert "no email claim" in excinfo.value.detail + + +@pytest.mark.asyncio +async def test_login_success(mocker): + """Test login success with existing user.""" + mock_validate_assertion = mocker.patch("auth.iap._validate_iap_assertion") + mock_get_user = mocker.patch("auth.iap.get_user_by_email_from_db") + mock_validate_user_info = mocker.patch("auth.iap._validate_user_info") + mock_create_jwt = mocker.patch("auth.iap.create_jwt_token") + mock_generate_csrf = mocker.patch("auth.iap.generate_csrf_token") + + mock_validate_assertion.return_value = {"email": "user@example.com"} + mock_user = mocker.MagicMock() + mock_user.uuid.hex = "user_uuid" + mock_get_user.return_value = mock_user + mock_create_jwt.return_value = "mocked_jwt" + mock_generate_csrf.return_value = "mocked_csrf" + + mock_db = mocker.MagicMock() + + response = await iap_auth.login(x_goog_iap_jwt_assertion="assertion", db=mock_db) + + assert isinstance(response, RedirectResponse) + assert response.status_code == 307 + mock_validate_user_info.assert_called_once() + mock_get_user.assert_called_once_with(mock_db, email="user@example.com") + + +@pytest.mark.asyncio +async def test_login_new_user(mocker): + """Test login with new user.""" + mock_validate_assertion = mocker.patch("auth.iap._validate_iap_assertion") + mock_get_user = mocker.patch("auth.iap.get_user_by_email_from_db") + _ = mocker.patch( + "auth.iap._validate_user_info" + ) # Needed to bypass list of allowed users + mock_create_user = mocker.patch("auth.iap.create_user_in_db") + mock_create_jwt = mocker.patch("auth.iap.create_jwt_token") + mock_generate_csrf = mocker.patch("auth.iap.generate_csrf_token") + + mock_validate_assertion.return_value = {"email": "new@example.com"} + mock_get_user.return_value = None + mock_user = mocker.MagicMock() + mock_user.uuid.hex = "new_uuid" + mock_create_user.return_value = mock_user + mock_create_jwt.return_value = "mocked_jwt" + mock_generate_csrf.return_value = "mocked_csrf" + + mock_db = mocker.MagicMock() + + response = await iap_auth.login(x_goog_iap_jwt_assertion="assertion", db=mock_db) + + assert isinstance(response, RedirectResponse) + mock_create_user.assert_called_once() + # The new user record is provisioned from the verified email claim. + _, kwargs = mock_create_user.call_args + new_user = kwargs.get("new_user") or mock_create_user.call_args[0][1] + assert new_user.email == "new@example.com" + assert new_user.auth_method == "iap" + + +@pytest.mark.asyncio +async def test_login_not_in_allowlist(mocker): + """Test login with a user that is not authorized.""" + mock_validate_assertion = mocker.patch("auth.iap._validate_iap_assertion") + mock_validate_assertion.return_value = {"email": "user@notallowed.com"} + mocker.patch("auth.iap.IAP_PUBLIC_ACCESS", False) + mocker.patch("auth.iap.IAP_ALLOW_LIST", ["user@allowed.com"]) + + mock_db = mocker.MagicMock() + + with pytest.raises(HTTPException) as excinfo: + await iap_auth.login(x_goog_iap_jwt_assertion="assertion", db=mock_db) + + assert excinfo.value.status_code == 401 diff --git a/uv.lock b/uv.lock index 176cbbb..847df41 100644 --- a/uv.lock +++ b/uv.lock @@ -1391,6 +1391,7 @@ dependencies = [ { name = "openrelik-common" }, { name = "prometheus-client" }, { name = "psycopg2-binary" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "python-jose", extra = ["cryptography"] }, { name = "python-magic" }, { name = "python-multipart" }, @@ -1430,6 +1431,7 @@ requires-dist = [ { name = "openrelik-common", specifier = ">=0.7.9,<0.8.0" }, { name = "prometheus-client", specifier = ">=0.21.0,<0.22.0" }, { name = "psycopg2-binary", specifier = ">=2.9.9,<3.0.0" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0,<3.0.0" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.4.0,<4.0.0" }, { name = "python-magic", specifier = ">=0.4.27,<0.5.0" }, { name = "python-multipart", specifier = ">=0.0.22,<0.1.0" }, @@ -1986,6 +1988,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pylint" version = "3.3.9"