diff --git a/docs/07-row_level_auth.ipynb b/docs/07-row_level_auth.ipynb index 0bfa5a4..e9a2a48 100644 --- a/docs/07-row_level_auth.ipynb +++ b/docs/07-row_level_auth.ipynb @@ -25,8 +25,11 @@ "proxy applies it to every relevant request — without touching stac-fastapi-pgstac at all.\n", "\n", "
\n", - "Note: This chapter needs the local docker-compose auth stack. It will not run against\n", - "the hosted workshop deployment.\n", + "Which stack you need. Sections 7.4 – 7.5.1 run against\n", + "either the local docker-compose stack or the hosted deployment — see\n", + "§7.3.3 for the deployed variant. Section 7.6 onward needs docker-compose,\n", + "because it creates and deletes records and the deployed STAC API has the transaction\n", + "extension disabled on purpose.\n", "
" ] }, @@ -218,7 +221,40 @@ "time: `private-alice-demo` and `private-bob-demo`. To try it, open the proxy's Swagger UI\n", "at `https://{project}-protected-stac.eoapi.dev/api.html`, click **Authorize**, sign in as\n", "`alice`, and call `GET /collections`: `private-alice-demo` is listed and\n", - "`private-bob-demo` is not. Signed out, neither appears." + "`private-bob-demo` is not. Signed out, neither appears.\n", + "\n", + "### Running the read-side against the deployment\n", + "\n", + "Sections 7.4 to 7.5.1 only read, so they work against the deployed proxy too. Swap the\n", + "setup cell in 7.4 for this, using the same workshop token `workshop_setup.setup()` asks\n", + "for:\n", + "\n", + "```python\n", + "from stac_auth import auth_headers, deployed_auth, get_cognito_token\n", + "\n", + "cfg = deployed_auth(workshop_token)\n", + "stac_api_endpoint = cfg[\"stac_endpoint\"]\n", + "\n", + "def token_for(user):\n", + " return get_cognito_token(user, cfg[\"password\"], cfg[\"client_id\"], cfg[\"region\"])\n", + "\n", + "alice, bob, anonymous = auth_headers(token_for(\"alice\")), auth_headers(token_for(\"bob\")), {}\n", + "```\n", + "\n", + "Then use the seeded collections in place of the ones 7.4 creates:\n", + "\n", + "```python\n", + "public_id = \"eoapi-workshop-sentinel-2-c1-l2a\"\n", + "alice_id, bob_id = \"private-alice-demo\", \"private-bob-demo\"\n", + "```\n", + "\n", + "and skip the item cells, since the seeded collections have no items. 7.5 and 7.5.1 then\n", + "run unchanged, and you stop before 7.6.\n", + "\n", + "Two things this shows that the local stack does not. The tokens are signed by a real\n", + "identity provider rather than a mock, and they carry no `stac/write` scope — so even\n", + "the write-rejection in 7.6 would fail for the *wrong* reason (route-level auth, not\n", + "row-level), which is precisely why that section stays local." ] }, { diff --git a/docs/stac_auth.py b/docs/stac_auth.py index 3d360c7..5664851 100644 --- a/docs/stac_auth.py +++ b/docs/stac_auth.py @@ -1,7 +1,12 @@ """ -Helpers for authenticated STAC transaction requests in the local docker-compose stack. +Helpers for authenticated STAC requests against the workshop's auth stack. -Requires MOCK_OIDC_ENDPOINT and STAC_API_ENDPOINT (stac-auth-proxy). +The local docker-compose stack is the default: `get_mock_oidc_token` needs +MOCK_OIDC_ENDPOINT and STAC_API_ENDPOINT (stac-auth-proxy). + +`deployed_auth` and `get_cognito_token` are the hosted equivalents, for running the +read-side of chapter 7 against the deployed proxy without docker. They cannot do writes: +the deployed STAC API has the transaction extension disabled on purpose. """ from __future__ import annotations @@ -78,3 +83,85 @@ def get_mock_oidc_token( def auth_headers(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} + + +def deployed_auth( + workshop_token: str, + config_url: str | None = None, + timeout: float = 20.0, +) -> dict[str, str]: + """Look up the deployed proxy endpoint and Cognito details. + + Args: + workshop_token: the bearer token for the config endpoint, the same one + `workshop_setup.setup()` asks for. + + Returns a dict with `stac_endpoint`, `client_id`, `region` and `password`. + """ + config_url = config_url or os.getenv( + "CONFIG_API_ENDPOINT", "https://workshop-config.eoapi.dev" + ) + response = httpx.get( + config_url, + headers=auth_headers(workshop_token), + timeout=timeout, + ) + response.raise_for_status() + config = response.json() + + discovery = config.get("oidc_discovery_url", "") + # https://cognito-idp..amazonaws.com//.well-known/... + region = discovery.split("://", 1)[-1].split(".")[1] if discovery else "" + + missing = [ + key + for key, value in { + "stac_auth_proxy_endpoint": config.get("stac_auth_proxy_endpoint"), + "oidc_client_id": config.get("oidc_client_id"), + }.items() + if not value + ] + if missing: + raise RuntimeError( + f"The config endpoint did not return {missing}. " + "The deployment may predate the Cognito auth proxy." + ) + + return { + "stac_endpoint": config["stac_auth_proxy_endpoint"], + "client_id": config["oidc_client_id"], + "region": region, + "password": config.get("workshop_user_password", ""), + } + + +def get_cognito_token( + username: str, + password: str, + client_id: str, + region: str, + timeout: float = 20.0, +) -> str: + """Return a Cognito access token for `username`, without a browser redirect. + + Uses `USER_PASSWORD_AUTH`, which the workshop's app client enables for exactly this + reason. The resulting token carries the `username` claim the row-level filter reads, + but *not* the `stac/write` scope, so it can read the catalog and never write to it. + """ + response = httpx.post( + f"https://cognito-idp.{region}.amazonaws.com/", + headers={ + "Content-Type": "application/x-amz-json-1.1", + "X-Amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth", + }, + json={ + "AuthFlow": "USER_PASSWORD_AUTH", + "ClientId": client_id, + "AuthParameters": {"USERNAME": username, "PASSWORD": password}, + }, + timeout=timeout, + ) + if response.status_code != 200: + raise RuntimeError(f"Cognito rejected the sign-in: {response.text}") + + return response.json()["AuthenticationResult"]["AccessToken"] diff --git a/infrastructure/app.py b/infrastructure/app.py index 29f9e0a..bfaadf5 100644 --- a/infrastructure/app.py +++ b/infrastructure/app.py @@ -378,6 +378,19 @@ def __init__( "http://localhost:8086", ], ), + # Lets a notebook sign in as alice or bob without a browser redirect, so + # chapter 7's read-side demo can run against this deployment and not only + # against docker-compose. + # + # This does not weaken the write protection. Cognito issues + # USER_PASSWORD_AUTH tokens with the scope `aws.cognito.signin.user.admin` + # and never the custom `stac/*` scopes, so such a token still fails the + # scope check in PRIVATE_ENDPOINTS. It carries `username`, which is all the + # row-level filter reads. + # + # `user_srp` is kept because naming any flow replaces Cognito's implicit + # defaults, and dropping SRP would be an unintended narrowing. + auth_flows=aws_cognito.AuthFlow(user_password=True, user_srp=True), ) # Two workshop identities sharing one password. Cognito creates users in