Skip to content

Commit

Permalink
Build JWT directly rather than (ab)use auth magic links
Browse files Browse the repository at this point in the history
We are switching from, really, one hack to another here, in order to be
able to run these tests against and in the dev/test environments.

Currently/previously, we used the magic links flow on authenticator to
get a logged-in session, but this only worked easily for accounts that
don't need any roles. To give them roles, we need to hit the account
store API directly to inject them. The account store API is internal
(not accessible over the public Internet) in dev/test environments, so
we would need to do some janky stuff to get access to it from eg local
machines and/or github actions.

Our current auth flow just ends up setting a cookie in the browser that
works across all FS domains and doesn't ever "call home" to verify the
details. So if we can just build a matching cookie and inject it into
the browser, those details will be accepted outright. We need to sign it
with a private key that the apps will trust, so we now need to read that
key appropriately for the dev/test environments. Then just build the
JSON blob, sign it, and stick it in the browser.
  • Loading branch information
samuelhwilliams committed Sep 13, 2024
1 parent 50c698d commit f007080
Show file tree
Hide file tree
Showing 4 changed files with 104 additions and 40 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ The e2e test for find currently requires a `test`-scoped API key for GOV.UK Noti
the test. Pass an environment variable called `E2E_NOTIFY_FIND_API_KEY` to allow this test to pass.

If you want to run the end-to-end tests against our deployed dev/test environments, you can do this by adding the
`--e2e-env` flag to the pytest command with a value of either `dev` or `test`.
`--e2e-env` flag to the pytest command with a value of either `dev` or `test`, and `--e2e-aws-vault-profile` with a value
that matches the aws-vault profile name for the matching environment. The tests expect a session to be available
without any input, so you must have authenticated already and have your credentials cached.

## Updating database migrations

Expand Down
10 changes: 10 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,23 @@

def pytest_addoption(parser):
parser.addoption("--e2e", action="store_true", default=False, help="run e2e (browser) tests")

# WARNING: Do not add an option for `prod` here. We *must* rework the e2e test authentication process before
# that would be something we could consider.
parser.addoption(
"--e2e-env",
action="store",
default="local",
help="choose the environment that e2e tests will target",
choices=("local", "dev", "test"),
)

parser.addoption(
"--e2e-aws-vault-profile",
action="store",
help="the aws-vault profile matching the env set in --e2e-env (for `dev` or `test` only)",
)

parser.addoption(
"--viewport",
default="1920x1080",
Expand Down
129 changes: 90 additions & 39 deletions tests/e2e_tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import base64
import datetime
import json
import subprocess
import uuid
from pathlib import Path

import boto3
import jwt
import pytest
import requests
from playwright._impl._errors import Error as PlaywrightError
from playwright.sync_api import Page
from playwright.sync_api import Browser, BrowserContext, Page
from pytest import FixtureRequest

from config import Config
from tests.e2e_tests.dataclasses import Account, FundingServiceDomains, TestFundConfig
from tests.e2e_tests.helpers import create_account_with_roles, generate_email_address
from tests.e2e_tests.pages.authenticator import MagicLinkPage, NewMagicLinkPage
from tests.e2e_tests.helpers import generate_email_address


@pytest.fixture(autouse=True)
Expand All @@ -22,18 +28,21 @@ def domains(request: FixtureRequest) -> FundingServiceDomains:

if e2e_env == "local":
return FundingServiceDomains(
cookie=".levellingup.gov.localhost",
authenticator=f"http://{Config.AUTHENTICATOR_HOST}.levellingup.gov.localhost:4004",
find=f"http://{Config.FIND_HOST}",
submit=f"http://{Config.SUBMIT_HOST}",
)
elif e2e_env == "dev":
return FundingServiceDomains(
cookie=".dev.access-funding.test.levellingup.gov.uk",
authenticator="https://authenticator.dev.access-funding.test.levellingup.gov.uk",
find="https://find-monitoring-data.dev.access-funding.test.levellingup.gov.uk",
submit="https://submit-monitoring-data.dev.access-funding.test.levellingup.gov.uk",
)
elif e2e_env == "test":
return FundingServiceDomains(
cookie=".test.access-funding.test.levellingup.gov.uk",
authenticator="https://authenticator.test.access-funding.test.levellingup.gov.uk",
find="https://find-monitoring-data.test.access-funding.test.levellingup.gov.uk",
submit="https://submit-monitoring-data.test.access-funding.test.levellingup.gov.uk",
Expand Down Expand Up @@ -61,6 +70,47 @@ def authenticator_fund_config(request: FixtureRequest) -> TestFundConfig:
raise ValueError(f"not configured for {e2e_env}")


def _read_jwt_signing_key(e2e_env: str, e2e_aws_vault_profile: str | None):
"""Reads the RS256 private key used to sign JWTs in our development environments.
This is an (unpleasant) workaround for our authentication journey not providing a suitable alternative.
"""
if e2e_env == "local":
_test_private_key_path = str(Path(__file__).parent.parent) + "/keys/rsa256/private.pem"
with open(_test_private_key_path, mode="rb") as private_key_file:
rsa256_private_key = private_key_file.read()

elif e2e_env in {"dev", "test"}:
param_name = f"/copilot/pre-award/{e2e_env}/secrets/RSA256_PRIVATE_KEY_BASE64"
if e2e_aws_vault_profile: # This flow is used locally
rsa256_private_key_base64 = json.loads(
subprocess.check_output(
[
"aws-vault",
"exec",
e2e_aws_vault_profile,
"--",
"aws",
"ssm",
"get-parameter",
"--name",
param_name,
"--with-decryption",
],
).decode()
)["Parameter"]["Value"]

else: # This flow is used in CI/CD where credentials are available implicitly
ssm_client = boto3.client("ssm")
rsa256_private_key_base64 = ssm_client.get_parameter(Name=param_name, WithDecryption=True)["Parameter"][
"Value"
]
rsa256_private_key = base64.b64decode(rsa256_private_key_base64)
del rsa256_private_key_base64

return rsa256_private_key


@pytest.fixture
def context(request: FixtureRequest, browser: Browser):
e2e_env = request.config.getoption("e2e_env")
Expand All @@ -83,45 +133,46 @@ def user_auth(
request: FixtureRequest,
domains: FundingServiceDomains,
authenticator_fund_config: TestFundConfig,
page: Page,
context: BrowserContext,
) -> Account:
e2e_env = request.config.getoption("e2e_env")
e2e_aws_vault_profile = request.config.getoption("e2e_aws_vault_profile")

email_address = generate_email_address(
test_name=request.node.originalname,
email_domain="communities.gov.uk",
)

user_roles = request.node.get_closest_marker("user_roles")

account = create_account_with_roles(
email_address=email_address,
roles=user_roles.args[0] if user_roles else [],
roles_marker = request.node.get_closest_marker("user_roles")
user_roles = roles_marker.args[0] if roles_marker else []

now = int(datetime.datetime.timestamp(datetime.datetime.now()))
jwt_data = {
"accountId": str(uuid.uuid4()),
"azureAdSubjectId": str(uuid.uuid4()),
"email": email_address,
"fullName": f"E2E Test User - {request.node.originalname}",
"roles": user_roles,
"iat": now,
"exp": now + (15 * 60), # 15 minutes from now
}

def create_token(payload):
algorithm = "RS256" # Must match the algorithm used by fsd-authenticator
return jwt.encode(payload, _read_jwt_signing_key(e2e_env, e2e_aws_vault_profile), algorithm=algorithm)

context.add_cookies(
[
{
"name": Config.FSD_USER_TOKEN_COOKIE_NAME,
"value": create_token(jwt_data),
"domain": domains.cookie,
"path": "/",
"httpOnly": True,
"secure": True,
"sameSize": "Strict",
}
]
)

response = requests.get(f"{domains.authenticator}/magic-links")
magic_links_before = set(response.json())

new_magic_link_page = NewMagicLinkPage(page, domain=domains.authenticator, fund_config=authenticator_fund_config)
new_magic_link_page.navigate()
new_magic_link_page.insert_email_address(account.email_address)
new_magic_link_page.press_continue()

response = requests.get(f"{domains.authenticator}/magic-links")
magic_links_after = set(response.json())

new_magic_links = magic_links_after - magic_links_before
for magic_link in new_magic_links:
if magic_link.startswith("link:"):
break
else:
raise KeyError("Could not generate/retrieve a new magic link via authenticator")

magic_link_id = magic_link.split(":")[1]
magic_link_page = MagicLinkPage(page, domain=domains.authenticator)

try:
magic_link_page.navigate(magic_link_id)
except PlaywrightError:
# FIXME: Authenticator gets into a weird redirect loop locally... We just ignore that error.
pass

return account
return Account(email_address=email_address, roles=user_roles)
1 change: 1 addition & 0 deletions tests/e2e_tests/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class Account:

@dataclass
class FundingServiceDomains:
cookie: str
authenticator: str
find: str
submit: str
Expand Down

0 comments on commit f007080

Please sign in to comment.