async def test_expiration(setup: SetupTest) -> None: """The cache is valid until half the lifetime of the child token.""" token_data = await setup.create_session_token(scopes=["read:all"]) lifetime = setup.config.token_lifetime now = current_datetime() storage = RedisStorage(TokenData, setup.config.session_secret, setup.redis) token_store = TokenRedisStore(storage, setup.logger) token_cache = setup.factory.create_token_cache() # Store a token whose expiration is five seconds more than half the # typical token lifetime in the future and cache that token as an internal # token for our session token. created = now - timedelta(seconds=lifetime.total_seconds() // 2) expires = created + setup.config.token_lifetime + timedelta(seconds=5) internal_token_data = TokenData( token=Token(), username=token_data.username, token_type=TokenType.internal, scopes=["read:all"], created=created, expires=expires, ) await token_store.store_data(internal_token_data) token_cache.store_internal_token( internal_token_data.token, token_data, "some-service", ["read:all"] ) # The cache should return this token. assert internal_token_data.token == await token_cache.get_internal_token( token_data, "some-service", ["read:all"] ) # Now change the expiration to be ten seconds earlier, which should make # the remaining lifetime less than half the total lifetime, and replace # replace the stored token with that new version. internal_token_data.expires = expires - timedelta(seconds=20) await token_store.store_data(internal_token_data) # The cache should now decline to return the token. assert not await token_cache.get_internal_token( token_data, "some-service", ["read:all"] ) # Do the same test with a notebook token. notebook_token_data = TokenData( token=Token(), username=token_data.username, token_type=TokenType.notebook, scopes=["read:all"], created=created, expires=expires, ) await token_store.store_data(notebook_token_data) token_cache.store_notebook_token(notebook_token_data.token, token_data) token = notebook_token_data.token assert token == await token_cache.get_notebook_token(token_data) notebook_token_data.expires = expires - timedelta(seconds=20) await token_store.store_data(notebook_token_data) assert not await token_cache.get_notebook_token(token_data)
def _valid_bootstrap_token(cls, v: Optional[str]) -> Optional[str]: if not v: return None try: Token.from_str(v) return v except Exception as e: raise ValueError(f"bootstrap_token not a valid token: {str(e)}")
async def test_invalid(config: Config, factory: ComponentFactory) -> None: redis = await redis_dependency() token_service = factory.create_token_service() expires = int(timedelta(days=1).total_seconds()) # No such key. token = Token() assert await token_service.get_data(token) is None # Invalid encrypted blob. await redis.set(f"token:{token.key}", "foo", ex=expires) assert await token_service.get_data(token) is None # Malformed session. fernet = Fernet(config.session_secret.encode()) raw_data = fernet.encrypt(b"malformed json") await redis.set(f"token:{token.key}", raw_data, ex=expires) assert await token_service.get_data(token) is None # Mismatched token. data = TokenData( token=Token(), username="******", token_type=TokenType.session, scopes=[], created=int(current_datetime().timestamp()), name="Some User", uid=12345, ) session = fernet.encrypt(data.json().encode()) await redis.set(f"token:{token.key}", session, ex=expires) assert await token_service.get_data(token) is None # Missing required fields. json_data = { "token": { "key": token.key, "secret": token.secret, }, "token_type": "session", "scopes": [], "created": int(current_datetime().timestamp()), "name": "Some User", } raw_data = fernet.encrypt(json.dumps(json_data).encode()) await redis.set(f"token:{token.key}", raw_data, ex=expires) assert await token_service.get_data(token) is None # Fix the session store and confirm we can retrieve the manually-stored # session. json_data["username"] = "******" raw_data = fernet.encrypt(json.dumps(json_data).encode()) await redis.set(f"token:{token.key}", raw_data, ex=expires) new_data = await token_service.get_data(token) assert new_data == TokenData.parse_obj(json_data)
async def test_token_info(driver: webdriver.Chrome, selenium_config: SeleniumConfig) -> None: cookie = await State(token=selenium_config.token).as_cookie() # Create a notebook token and an internal token. r = httpx.get( urljoin(selenium_config.url, "/auth"), params={ "scope": "exec:test", "notebook": "true" }, headers={"Cookie": f"{COOKIE_NAME}={cookie}"}, ) assert r.status_code == 200 notebook_token = Token.from_str(r.headers["X-Auth-Request-Token"]) r = httpx.get( urljoin(selenium_config.url, "/auth"), params={ "scope": "exec:test", "delegate_to": "service" }, headers={"Cookie": f"{COOKIE_NAME}={cookie}"}, ) assert r.status_code == 200 internal_token = Token.from_str(r.headers["X-Auth-Request-Token"]) # Load the token page and go to the history for our session token. driver.get(urljoin(selenium_config.url, "/auth/tokens")) tokens_page = TokensPage(driver) session_tokens = tokens_page.get_tokens(TokenType.session) session_token = next(t for t in session_tokens if t.token == selenium_config.token.key) session_token.click_token() # We should now be at the token information page for the session token. data_page = TokenDataPage(driver) assert data_page.username == "testuser" assert data_page.token_type == "session" scopes = sorted(selenium_config.config.known_scopes.keys()) assert data_page.scopes == ", ".join(scopes) history = data_page.get_change_history() assert len(history) == 3 assert history[0].action == "create" assert history[0].token == internal_token.key assert history[0].scopes == "" assert history[1].action == "create" assert history[1].token == notebook_token.key assert history[1].scopes == ", ".join(scopes) assert history[2].action == "create" assert history[2].token == selenium_config.token.key assert history[2].scopes == ", ".join(scopes)
async def test_invalid(factory: ComponentFactory) -> None: """Invalid tokens should not be returned even if cached.""" token_data = await create_session_token(factory, scopes=["read:all"]) token_cache = factory.create_token_cache_service() internal_token = Token() notebook_token = Token() token_cache.store_internal_token(internal_token, token_data, "some-service", ["read:all"]) token_cache.store_notebook_token(notebook_token, token_data) assert internal_token != await token_cache.get_internal_token( token_data, "some-service", ["read:all"], "127.0.0.1") assert notebook_token != await token_cache.get_notebook_token( token_data, "127.0.0.1")
async def test_invalid(setup: SetupTest) -> None: """Invalid tokens should not be returned even if cached.""" token_data = await setup.create_session_token(scopes=["read:all"]) token_cache = setup.factory.create_token_cache() internal_token = Token() notebook_token = Token() token_cache.store_internal_token( internal_token, token_data, "some-service", ["read:all"] ) token_cache.store_notebook_token(notebook_token, token_data) assert not await token_cache.get_internal_token( token_data, "some-service", ["read:all"] ) assert not await token_cache.get_notebook_token(token_data)
async def post_analyze( token_str: str = Form( ..., alias="token", title="Token to analyze", example="gt-db59fbkT5LrGHvhLMglNWw.G3NEmhWZr8JwO8AQ8sIWpQ", ), context: RequestContext = Depends(context_dependency), ) -> Dict[str, Dict[str, Any]]: """Analyze a token. Expects a POST with a single parameter, ``token``, containing the token. Returns a JSON structure with details about that token. """ try: token = Token.from_str(token_str) except InvalidTokenError as e: return {"token": {"errors": [str(e)], "valid": False}} token_service = context.factory.create_token_service() token_data = await token_service.get_data(token) if not token_data: return { "handle": token.dict(), "token": { "errors": ["Invalid token"], "valid": False }, } result = token_data_to_analysis(token_data) result["handle"] = token.dict() return result
async def token_data_from_secret(token_service: TokenService, secret: V1Secret) -> TokenData: assert "token" in secret.data token = b64decode(secret.data["token"].encode()).decode() data = await token_service.get_data(Token.from_str(token)) assert data return data
async def test_internal(setup: SetupTest) -> None: token_data = await setup.create_session_token( group_names=["admin"], scopes=["exec:admin", "read:all", "read:some"]) assert token_data.expires r = await setup.client.get( "/auth", params={ "scope": "exec:admin", "delegate_to": "a-service", "delegate_scope": " read:some ,read:all ", }, headers={"Authorization": f"Bearer {token_data.token}"}, ) assert r.status_code == 200 internal_token = Token.from_str(r.headers["X-Auth-Request-Token"]) assert internal_token != token_data.token r = await setup.client.get( "/auth/api/v1/token-info", headers={"Authorization": f"Bearer {internal_token}"}, ) assert r.status_code == 200 assert r.json() == { "token": internal_token.key, "username": token_data.username, "token_type": "internal", "scopes": ["read:all", "read:some"], "service": "a-service", "created": ANY, "expires": int(token_data.expires.timestamp()), "parent": token_data.token.key, } # Requesting a token with the same parameters returns the same token. r = await setup.client.get( "/auth", params={ "scope": "exec:admin", "delegate_to": "a-service", "delegate_scope": "read:all,read:some", }, headers={"Authorization": f"Bearer {token_data.token}"}, ) assert r.status_code == 200 assert internal_token == Token.from_str(r.headers["X-Auth-Request-Token"])
async def add_expired_session_token( user_info: TokenUserInfo, *, scopes: List[str], ip_address: str, session: AsyncSession, ) -> None: """Add an expired session token to the database. This requires going beneath the service layer, since the service layer rejects creation of expired tokens (since apart from testing this isn't a sensible thing to want to do). This does not add the token to Redis, since Redis will refuse to add it with a negative expiration time, so can only be used for tests that exclusively use the database. Parameters ---------- user_info : `gafaelfawr.models.token.TokenUserInfo` The user information to associate with the token. scopes : List[`str`] The scopes of the token. ip_address : `str` The IP address from which the request came. session : `sqlalchemy.ext.asyncio.AsyncSession` The database session. """ token_db_store = TokenDatabaseStore(session) token_change_store = TokenChangeHistoryStore(session) token = Token() created = current_datetime() expires = created - timedelta(minutes=10) data = TokenData( token=token, token_type=TokenType.session, scopes=scopes, created=created, expires=expires, **user_info.dict(), ) history_entry = TokenChangeHistoryEntry( token=token.key, username=data.username, token_type=TokenType.session, scopes=scopes, expires=expires, actor=data.username, action=TokenChange.create, ip_address=ip_address, event_time=created, ) await token_db_store.add(data) await token_change_store.add(history_entry)
async def create_session_token(self, user_info: TokenUserInfo, *, scopes: List[str], ip_address: str) -> Token: """Create a new session token. Parameters ---------- user_info : `gafaelfawr.models.token.TokenUserInfo` The user information to associate with the token. scopes : List[`str`] The scopes of the token. ip_address : `str` The IP address from which the request came. Returns ------- token : `gafaelfawr.models.token.Token` The newly-created token. Raises ------ gafaelfawr.exceptions.PermissionDeniedError If the provided username is invalid. """ self._validate_username(user_info.username) scopes = sorted(scopes) token = Token() created = current_datetime() expires = created + self._config.token_lifetime data = TokenData( token=token, token_type=TokenType.session, scopes=scopes, created=created, expires=expires, **user_info.dict(), ) history_entry = TokenChangeHistoryEntry( token=token.key, username=data.username, token_type=TokenType.session, scopes=scopes, expires=expires, actor=data.username, action=TokenChange.create, ip_address=ip_address, event_time=created, ) await self._token_redis_store.store_data(data) with self._transaction_manager.transaction(): self._token_db_store.add(data) self._token_change_store.add(history_entry) return token
async def test_internal(client: AsyncClient, factory: ComponentFactory) -> None: data = await create_session_token(factory, scopes=["exec:test", "read:all"]) await set_session_cookie(client, data.token) request_awaits = [] for _ in range(100): request_awaits.append( client.get( "/auth", params={ "scope": "exec:test", "delegate_to": "a-service", "delegate_scope": "read:all", }, )) responses = await asyncio.gather(*request_awaits) assert responses[0].status_code == 200 token = Token.from_str(responses[0].headers["X-Auth-Request-Token"]) for r in responses: assert r.status_code == 200 assert Token.from_str(r.headers["X-Auth-Request-Token"]) == token request_awaits = [] for _ in range(100): request_awaits.append( client.get( "/auth", params={ "scope": "exec:test", "delegate_to": "a-service", "delegate_scope": "exec:test", }, )) responses = await asyncio.gather(*request_awaits) assert responses[0].status_code == 200 new_token = Token.from_str(responses[0].headers["X-Auth-Request-Token"]) assert new_token != token for r in responses: assert r.status_code == 200 assert Token.from_str(r.headers["X-Auth-Request-Token"]) == new_token
async def run_app(tmp_path: Path, settings_path: Path) -> AsyncIterator[SeleniumConfig]: """Run the application as a separate process for Selenium access. Parameters ---------- tmp_path : `pathlib.Path` The temporary directory for testing. settings_path : `pathlib.Path` The path to the settings file. """ config_dependency.set_settings_path(str(settings_path)) config = await config_dependency() token_path = tmp_path / "token" # Create the socket that the app will listen on. s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.bind(("127.0.0.1", 0)) port = s.getsockname()[1] # Spawn the app in a separate process using uvicorn. cmd = [ "uvicorn", "--fd", "0", "--factory", "tests.support.selenium:create_app", ] logging.info("Starting server with command %s", " ".join(cmd)) p = subprocess.Popen( cmd, cwd=str(tmp_path), stdin=s.fileno(), env={ **os.environ, "GAFAELFAWR_SETTINGS_PATH": str(settings_path), "GAFAELFAWR_TEST_TOKEN_PATH": str(token_path), "PYTHONPATH": os.getcwd(), }, ) s.close() logging.info("Waiting for server to start") _wait_for_server(port) try: selenium_config = SeleniumConfig( config=config, token=Token.from_str(token_path.read_text()), url=f"http://localhost:{port}", ) yield selenium_config finally: p.terminate()
async def test_invalid_auth(setup: SetupTest) -> None: r = await setup.client.get( "/auth", params={"scope": "exec:admin"}, headers={"Authorization": "Bearer"}, ) assert r.status_code == 400 authenticate = parse_www_authenticate(r.headers["WWW-Authenticate"]) assert isinstance(authenticate, AuthErrorChallenge) assert authenticate.auth_type == AuthType.Bearer assert authenticate.realm == setup.config.realm assert authenticate.error == AuthError.invalid_request r = await setup.client.get( "/auth", params={"scope": "exec:admin"}, headers={"Authorization": "token foo"}, ) assert r.status_code == 400 authenticate = parse_www_authenticate(r.headers["WWW-Authenticate"]) assert isinstance(authenticate, AuthErrorChallenge) assert authenticate.auth_type == AuthType.Bearer assert authenticate.realm == setup.config.realm assert authenticate.error == AuthError.invalid_request r = await setup.client.get( "/auth", params={"scope": "exec:admin"}, headers={"Authorization": "Bearer token"}, ) assert r.status_code == 401 assert r.headers["Cache-Control"] == "no-cache, must-revalidate" authenticate = parse_www_authenticate(r.headers["WWW-Authenticate"]) assert isinstance(authenticate, AuthErrorChallenge) assert authenticate.auth_type == AuthType.Bearer assert authenticate.realm == setup.config.realm assert authenticate.error == AuthError.invalid_token # Create a nonexistent token. token = Token() r = await setup.client.get( "/auth", params={"scope": "exec:admin"}, headers={"Authorization": f"Bearer {token}"}, ) assert r.status_code == 401 assert r.headers["Cache-Control"] == "no-cache, must-revalidate" authenticate = parse_www_authenticate(r.headers["WWW-Authenticate"]) assert isinstance(authenticate, AuthErrorChallenge) assert authenticate.auth_type == AuthType.Bearer assert authenticate.realm == setup.config.realm assert authenticate.error == AuthError.invalid_token
async def test_no_expires( client: AsyncClient, factory: ComponentFactory ) -> None: """Test creating a user token that doesn't expire.""" token_data = await create_session_token(factory) csrf = await set_session_cookie(client, token_data.token) r = await client.post( f"/auth/api/v1/users/{token_data.username}/tokens", headers={"X-CSRF-Token": csrf}, json={"token_name": "some token"}, ) assert r.status_code == 201 token_url = r.headers["Location"] r = await client.get(token_url) assert "expires" not in r.json() # Create a user token with an expiration and then adjust it to not expire. now = datetime.now(tz=timezone.utc).replace(microsecond=0) expires = now + timedelta(days=2) r = await client.post( f"/auth/api/v1/users/{token_data.username}/tokens", headers={"X-CSRF-Token": csrf}, json={ "token_name": "another token", "expires": int(expires.timestamp()), }, ) assert r.status_code == 201 user_token = Token.from_str(r.json()["token"]) token_service = factory.create_token_service() user_token_data = await token_service.get_data(user_token) assert user_token_data and user_token_data.expires == expires token_url = r.headers["Location"] r = await client.get(token_url) assert r.json()["expires"] == int(expires.timestamp()) r = await client.patch( token_url, headers={"X-CSRF-Token": csrf}, json={"expires": None}, ) assert r.status_code == 201 assert "expires" not in r.json() # Check that the expiration was also changed in Redis. user_token_data = await token_service.get_data(user_token) assert user_token_data and user_token_data.expires is None
async def _update_service_secret( self, service_secret: ServiceSecret ) -> None: """Verify that a service secret is still correct. This checks that the service token is still valid and replaces it with a new one if not. """ name = service_secret.secret_name namespace = service_secret.secret_namespace try: secret = self._storage.get_secret( name, namespace, SecretType.service ) except KubernetesError as e: msg = f"Updating {namespace}/{name} failed" self._logger.error(msg, error=str(e)) return if not secret: self._logger.error( f"Updating {namespace}/{name} failed", error=f"Secret {namespace}/{name} not found while updating", ) return valid = False if "token" in secret.data: try: token_str = b64decode(secret.data["token"]).decode() token = Token.from_str(token_str) valid = await self._check_service_token(token, service_secret) except Exception: valid = False if valid: return # The token is not valid. Replace the secret. token = await self._create_service_token(service_secret) try: self._storage.patch_secret(name, namespace, token) except KubernetesError as e: msg = f"Updating {namespace}/{name} failed" self._logger.error(msg, error=str(e)) else: self._logger.info( f"Updated {namespace}/{name} secret", service=service_secret.service, scopes=service_secret.scopes, )
def test_token_from_str() -> None: bad_tokens = [ "", ".", "MLF5MB3Peg79wEC0BY8U8Q", "MLF5MB3Peg79wEC0BY8U8Q.", "gt-", "gt-.", "gt-MLF5MB3Peg79wEC0BY8U8Q", "gt-MLF5MB3Peg79wEC0BY8U8Q.", "gt-.ChbkqEyp3EIJ2e_1Sqff3w", "gt-NOT.VALID", "gt-MLF5MB3Peg79wEC0BY8U8Q.ChbkqEyp3EIJ2e_1Sqff3w.!!!!", "gtMLF5MB3Peg79wEC0BY8U8Q.ChbkqEyp3EIJ2e_1Sqff3w", ] for token_str in bad_tokens: with pytest.raises(InvalidTokenError): Token.from_str(token_str) token_str = "gt-MLF5MB3Peg79wEC0BY8U8Q.ChbkqEyp3EIJ2e_1Sqff3w" token = Token.from_str(token_str) assert token.key == "MLF5MB3Peg79wEC0BY8U8Q" assert token.secret == "ChbkqEyp3EIJ2e_1Sqff3w" assert str(token) == token_str
def parse_settings(path: Path, fix_token: bool = False) -> None: """Parse the settings file and see if any exceptions are thrown. Parameters ---------- path : `pathlib.Path` The path to the settings file to test. fix_token : `bool`, optional Whether to fix an invalid ``bootstrap_token`` before checking the settings file. Some examples have intentionally invalid tokens. """ with path.open("r") as f: settings = yaml.safe_load(f) # Avoid errors from an invalid bootstrap token in one of the examples. if fix_token and "bootstrap_token" in settings: settings["bootstrap_token"] = str(Token()) Settings.parse_obj(settings)
def run_app(tmp_path: Path, settings_path: Path) -> Iterator[SeleniumConfig]: """Run the application as a separate process for Selenium access. Parameters ---------- tmp_path : `pathlib.Path` The temporary directory for testing. settings_path : `pathlib.Path` The path to the settings file. """ config_dependency.set_settings_path(str(settings_path)) config = config_dependency() initialize_database(config) token_path = tmp_path / "token" app_source = APP_TEMPLATE.format( settings_path=str(settings_path), token_path=str(token_path), ) app_path = tmp_path / "testing.py" with app_path.open("w") as f: f.write(app_source) s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.bind(("127.0.0.1", 0)) port = s.getsockname()[1] cmd = ["uvicorn", "--fd", "0", "testing:app"] logging.info("Starting server with command %s", " ".join(cmd)) p = subprocess.Popen(cmd, cwd=str(tmp_path), stdin=s.fileno()) s.close() logging.info("Waiting for server to start") _wait_for_server(port) try: selenium_config = SeleniumConfig( token=Token.from_str(token_path.read_text()), url=f"http://localhost:{port}", ) yield selenium_config finally: p.terminate()
async def test_login_no_auth(client: AsyncClient, config: Config, factory: ComponentFactory) -> None: r = await client.get("/auth/api/v1/login") assert_unauthorized_is_correct(r, config) # An Authorization header with a valid token still redirects. token_data = await create_session_token(factory) r = await client.get( "/auth/api/v1/login", headers={"Authorization": f"bearer {token_data.token}"}, ) assert_unauthorized_is_correct(r, config) # A token with no underlying Redis representation is ignored. state = State(token=Token()) r = await client.get( "/auth/api/v1/login", cookies={COOKIE_NAME: await state.as_cookie()}, ) assert_unauthorized_is_correct(r, config) # Likewise with a cookie containing a malformed token. This requires a # bit more work to assemble. key = config.session_secret.encode() fernet = Fernet(key) data = {"token": "bad-token"} bad_cookie = fernet.encrypt(json.dumps(data).encode()).decode() r = await client.get( "/auth/api/v1/login", cookies={COOKIE_NAME: bad_cookie}, ) assert_unauthorized_is_correct(r, config) # And finally check with a mangled state that won't decrypt. bad_cookie = "XXX" + await state.as_cookie() r = await client.get( "/auth/api/v1/login", cookies={COOKIE_NAME: bad_cookie}, ) assert_unauthorized_is_correct(r, config)
async def test_create_not_ours(setup: SetupTest, mock_kubernetes: MockCoreV1Api, caplog: LogCaptureFixture) -> None: assert setup.config.kubernetes assert len(setup.config.kubernetes.service_secrets) >= 1 service_secret = setup.config.kubernetes.service_secrets[-1] kubernetes_service = setup.factory.create_kubernetes_service() # Create a secret that should exist but doesn't have our annotation. secret = V1Secret( api_version="v1", data={"token": token_as_base64(Token())}, metadata=V1ObjectMeta( name=service_secret.secret_name, namespace=service_secret.secret_namespace, ), type="Opaque", ) mock_kubernetes.create_namespaced_secret(service_secret.secret_namespace, secret) # Now run the synchronization. secret_one and secret_two should be left # unchanged, and we should log errors about failing to do the update. await kubernetes_service.update_service_secrets() objects = mock_kubernetes.get_all_objects_for_test() assert secret in objects assert json.loads(caplog.record_tuples[-1][2]) == { "event": (f"Creating {service_secret.secret_namespace}" f"/{service_secret.secret_name} failed"), "error": (f"Kubernetes API error: (500)\n" f"Reason: {service_secret.secret_namespace}" f"/{service_secret.secret_name} exists\n"), "level": "error", "logger": "gafaelfawr", }
def from_cookie(cls, cookie: str, request: Optional[Request]) -> State: """Reconstruct state from an encrypted cookie. Parameters ---------- cookie : `str` The encrypted cookie value. key : `bytes` The `~cryptography.fernet.Fernet` key used to decrypt it. request : `fastapi.Request` or `None` The request, used for logging. If not provided (primarily for the test suite), invalid state cookies will not be logged. Returns ------- state : `State` The state represented by the cookie. """ key = config_dependency().session_secret.encode() fernet = Fernet(key) try: data = json.loads(fernet.decrypt(cookie.encode()).decode()) token = None if "token" in data: token = Token.from_str(data["token"]) except Exception as e: if request: logger = get_logger(request) logger.warning("Discarding invalid state cookie", error=str(e)) return cls() return cls( csrf=data.get("csrf"), token=token, return_url=data.get("return_url"), state=data.get("state"), )
async def test_ignore(setup: SetupTest, mock_kubernetes: MockCoreV1Api) -> None: assert setup.config.kubernetes kubernetes_service = setup.factory.create_kubernetes_service() # Create a secret without the expected label. secret_one = V1Secret( api_version="v1", data={"foo": "bar"}, metadata=V1ObjectMeta(name="secret-one", namespace="mobu"), type="Opaque", ) mock_kubernetes.create_namespaced_secret("mobu", secret_one) # Create a secret with the expected label but a different value. secret_two = V1Secret( api_version="v1", data={"token": token_as_base64(Token())}, metadata=V1ObjectMeta( labels={KUBERNETES_TOKEN_TYPE_LABEL: "other"}, name="secret-two", namespace="elsewhere", ), type="Opaque", ) mock_kubernetes.create_namespaced_secret("elsewhere", secret_two) # Update the secrets. Both of our secrets should survive unmolested. await kubernetes_service.update_service_secrets() objects = mock_kubernetes.get_all_objects_for_test() assert secret_one in objects assert secret_two in objects # Delete our secrets and then check that the created secrets are right. mock_kubernetes.delete_namespaced_secret("secret-one", "mobu") mock_kubernetes.delete_namespaced_secret("secret-two", "elsewhere") await assert_kubernetes_secrets_match_config(setup, mock_kubernetes)
async def test_create_admin(setup: SetupTest) -> None: """Test creating a token through the admin interface.""" token_data = await setup.create_session_token(scopes=["exec:admin"]) csrf = await setup.login(token_data.token) r = await setup.client.post( "/auth/api/v1/tokens", headers={"X-CSRF-Token": csrf}, json={ "username": "******", "token_type": "service" }, ) assert r.status_code == 403 token_data = await setup.create_session_token(scopes=["admin:token"]) csrf = await setup.login(token_data.token) now = datetime.now(tz=timezone.utc) expires = int((now + timedelta(days=2)).timestamp()) r = await setup.client.post( "/auth/api/v1/tokens", headers={"X-CSRF-Token": csrf}, json={ "username": "******", "token_type": "service", "scopes": ["admin:token"], "expires": expires, "name": "A Service", "uid": 1234, "email": "*****@*****.**", "groups": [{ "name": "some-group", "id": 12381 }], }, ) assert r.status_code == 201 assert r.json() == {"token": ANY} service_token = Token.from_str(r.json()["token"]) token_url = f"/auth/api/v1/users/a-service/tokens/{service_token.key}" assert r.headers["Location"] == token_url setup.logout() r = await setup.client.get( "/auth/api/v1/token-info", headers={"Authorization": f"bearer {str(service_token)}"}, ) assert r.status_code == 200 assert r.json() == { "token": service_token.key, "username": "******", "token_type": "service", "scopes": ["admin:token"], "created": ANY, "expires": expires, } r = await setup.client.get( "/auth/api/v1/user-info", headers={"Authorization": f"bearer {str(service_token)}"}, ) assert r.status_code == 200 assert r.json() == { "username": "******", "name": "A Service", "email": "*****@*****.**", "uid": 1234, "email": "*****@*****.**", "groups": [{ "name": "some-group", "id": 12381 }], } r = await setup.client.post( "/auth/api/v1/tokens", headers={"Authorization": f"bearer {str(service_token)}"}, json={ "username": "******", "token_type": "session" }, ) assert r.status_code == 422 r = await setup.client.post( "/auth/api/v1/tokens", headers={"Authorization": f"bearer {str(service_token)}"}, json={ "username": "******", "token_type": "user" }, ) assert r.status_code == 422 r = await setup.client.post( "/auth/api/v1/tokens", headers={"Authorization": f"bearer {str(service_token)}"}, json={ "username": "******", "token_type": "user", "token_name": "some token", "expires": int(datetime.now(tz=timezone.utc).timestamp()), }, ) assert r.status_code == 422 assert r.json()["detail"][0]["type"] == "invalid_expires" r = await setup.client.post( "/auth/api/v1/tokens", headers={"Authorization": f"bearer {str(service_token)}"}, json={ "username": "******", "token_type": "user", "token_name": "some token", "scopes": ["bogus:scope"], }, ) assert r.status_code == 422 assert r.json()["detail"][0]["type"] == "invalid_scopes" r = await setup.client.post( "/auth/api/v1/tokens", headers={"Authorization": f"bearer {str(service_token)}"}, json={ "username": "******", "token_type": "user", "token_name": "some token", }, ) assert r.status_code == 201 assert r.json() == {"token": ANY} user_token = Token.from_str(r.json()["token"]) token_url = f"/auth/api/v1/users/a-user/tokens/{user_token.key}" assert r.headers["Location"] == token_url # Successfully create a user token. r = await setup.client.get( "/auth/api/v1/token-info", headers={"Authorization": f"bearer {str(user_token)}"}, ) assert r.status_code == 200 assert r.json() == { "token": user_token.key, "username": "******", "token_type": "user", "token_name": "some token", "scopes": [], "created": ANY, } r = await setup.client.get( "/auth/api/v1/user-info", headers={"Authorization": f"bearer {str(user_token)}"}, ) assert r.status_code == 200 assert r.json() == {"username": "******"} # Check handling of duplicate token name errors. r = await setup.client.post( "/auth/api/v1/tokens", headers={"Authorization": f"bearer {str(service_token)}"}, json={ "username": "******", "token_type": "user", "token_name": "some token", }, ) assert r.status_code == 422 assert r.json()["detail"][0]["type"] == "duplicate_token_name" # Check handling of an invalid username. r = await setup.client.post( "/auth/api/v1/tokens", headers={"Authorization": f"bearer {str(service_token)}"}, json={ "username": "******", "token_type": "user", "token_name": "some token", }, ) assert r.status_code == 422 # Check that the bootstrap token also works. r = await setup.client.post( "/auth/api/v1/tokens", headers={ "Authorization": f"bearer {str(setup.config.bootstrap_token)}" }, json={ "username": "******", "token_type": "service" }, ) assert r.status_code == 201
async def test_create_delete_modify(setup: SetupTest, caplog: LogCaptureFixture) -> None: user_info = TokenUserInfo( username="******", name="Example Person", email="*****@*****.**", uid=45613, groups=[TokenGroup(name="foo", id=12313)], ) token_service = setup.factory.create_token_service() session_token = await token_service.create_session_token( user_info, scopes=["read:all", "exec:admin", "user:token"], ip_address="127.0.0.1", ) csrf = await setup.login(session_token) expires = current_datetime() + timedelta(days=100) r = await setup.client.post( "/auth/api/v1/users/example/tokens", headers={"X-CSRF-Token": csrf}, json={ "token_name": "some token", "scopes": ["read:all"], "expires": int(expires.timestamp()), }, ) assert r.status_code == 201 assert r.json() == {"token": ANY} user_token = Token.from_str(r.json()["token"]) token_url = r.headers["Location"] assert token_url == f"/auth/api/v1/users/example/tokens/{user_token.key}" r = await setup.client.get(token_url) assert r.status_code == 200 info = r.json() assert info == { "token": user_token.key, "username": "******", "token_name": "some token", "token_type": "user", "scopes": ["read:all"], "created": ANY, "expires": int(expires.timestamp()), } # Check that this is the same information as is returned by the token-info # route. This is a bit tricky to do since the cookie will take precedence # over the Authorization header, but we can't just delete the cookie since # we'll lose the CSRF token. Save the cookie and delete it, and then # later restore it. cookie = setup.client.cookies.pop(COOKIE_NAME) r = await setup.client.get( "/auth/api/v1/token-info", headers={"Authorization": f"bearer {user_token}"}, ) assert r.status_code == 200 assert r.json() == info setup.client.cookies.set(COOKIE_NAME, cookie, domain=TEST_HOSTNAME) # Listing all tokens for this user should return the user token and a # session token. r = await setup.client.get("/auth/api/v1/users/example/tokens") assert r.status_code == 200 assert r.json() == sorted( [ { "token": session_token.key, "username": "******", "token_type": "session", "scopes": ["exec:admin", "read:all", "user:token"], "created": ANY, "expires": ANY, }, info, ], key=lambda t: t["token"], ) # Change the name, scope, and expiration of the token. caplog.clear() new_expires = current_datetime() + timedelta(days=200) r = await setup.client.patch( token_url, headers={"X-CSRF-Token": csrf}, json={ "token_name": "happy token", "scopes": ["exec:admin"], "expires": int(new_expires.timestamp()), }, ) assert r.status_code == 201 assert r.json() == { "token": user_token.key, "username": "******", "token_name": "happy token", "token_type": "user", "scopes": ["exec:admin"], "created": ANY, "expires": int(new_expires.timestamp()), } # Check the logging. Regression test for a bug where new expirations # would be logged as raw datetime objects instead of timestamps. log = json.loads(caplog.record_tuples[0][2]) assert log == { "expires": int(new_expires.timestamp()), "event": "Modified token", "key": user_token.key, "level": "info", "logger": "gafaelfawr", "method": "PATCH", "path": token_url, "remote": "127.0.0.1", "request_id": ANY, "scope": "exec:admin read:all user:token", "token": session_token.key, "token_name": "happy token", "token_scope": "exec:admin", "token_source": "cookie", "user": "******", "user_agent": ANY, } # Delete the token. r = await setup.client.delete(token_url, headers={"X-CSRF-Token": csrf}) assert r.status_code == 204 r = await setup.client.get(token_url) assert r.status_code == 404 # Deleting again should return 404. r = await setup.client.delete(token_url, headers={"X-CSRF-Token": csrf}) assert r.status_code == 404 # This user should now have only one token. r = await setup.client.get("/auth/api/v1/users/example/tokens") assert r.status_code == 200 assert len(r.json()) == 1 # We should be able to see the change history for the token. r = await setup.client.get(token_url + "/change-history") assert r.status_code == 200 assert r.json() == [ { "token": user_token.key, "username": "******", "token_type": "user", "token_name": "happy token", "scopes": ["exec:admin"], "expires": int(new_expires.timestamp()), "actor": "example", "action": "revoke", "ip_address": "127.0.0.1", "event_time": ANY, }, { "token": user_token.key, "username": "******", "token_type": "user", "token_name": "happy token", "scopes": ["exec:admin"], "expires": int(new_expires.timestamp()), "actor": "example", "action": "edit", "old_token_name": "some token", "old_scopes": ["read:all"], "old_expires": int(expires.timestamp()), "ip_address": "127.0.0.1", "event_time": ANY, }, { "token": user_token.key, "username": "******", "token_type": "user", "token_name": "some token", "scopes": ["read:all"], "expires": int(expires.timestamp()), "actor": "example", "action": "create", "ip_address": "127.0.0.1", "event_time": ANY, }, ]
async def test_create_delete_modify( client: AsyncClient, factory: ComponentFactory, caplog: LogCaptureFixture ) -> None: user_info = TokenUserInfo( username="******", name="Example Person", email="*****@*****.**", uid=45613, groups=[TokenGroup(name="foo", id=12313)], ) token_service = factory.create_token_service() async with factory.session.begin(): session_token = await token_service.create_session_token( user_info, scopes=["read:all", "exec:admin", "user:token"], ip_address="127.0.0.1", ) csrf = await set_session_cookie(client, session_token) expires = current_datetime() + timedelta(days=100) r = await client.post( "/auth/api/v1/users/example/tokens", headers={"X-CSRF-Token": csrf}, json={ "token_name": "some token", "scopes": ["read:all"], "expires": int(expires.timestamp()), }, ) assert r.status_code == 201 assert r.json() == {"token": ANY} user_token = Token.from_str(r.json()["token"]) token_url = r.headers["Location"] assert token_url == f"/auth/api/v1/users/example/tokens/{user_token.key}" r = await client.get(token_url) assert r.status_code == 200 info = r.json() assert info == { "token": user_token.key, "username": "******", "token_name": "some token", "token_type": "user", "scopes": ["read:all"], "created": ANY, "expires": int(expires.timestamp()), } # Check that this is the same information as is returned by the token-info # route. This is a bit tricky to do since the cookie will take precedence # over the Authorization header, but we can't just delete the cookie since # we'll lose the CSRF token. Save the cookie and delete it, and then # later restore it. cookie = client.cookies.pop(COOKIE_NAME) r = await client.get( "/auth/api/v1/token-info", headers={"Authorization": f"bearer {user_token}"}, ) assert r.status_code == 200 assert r.json() == info client.cookies.set(COOKIE_NAME, cookie, domain=TEST_HOSTNAME) # Listing all tokens for this user should return the user token and a # session token. r = await client.get("/auth/api/v1/users/example/tokens") assert r.status_code == 200 data = r.json() # Adjust for sorting, which will be by creation date and then token. assert len(data) == 2 if data[0] == info: session_info = data[1] else: assert data[1] == info session_info = data[0] assert session_info == { "token": session_token.key, "username": "******", "token_type": "session", "scopes": ["exec:admin", "read:all", "user:token"], "created": ANY, "expires": ANY, } # Change the name, scope, and expiration of the token. caplog.clear() new_expires = current_datetime() + timedelta(days=200) r = await client.patch( token_url, headers={"X-CSRF-Token": csrf}, json={ "token_name": "happy token", "scopes": ["exec:admin"], "expires": int(new_expires.timestamp()), }, ) assert r.status_code == 201 assert r.json() == { "token": user_token.key, "username": "******", "token_name": "happy token", "token_type": "user", "scopes": ["exec:admin"], "created": ANY, "expires": int(new_expires.timestamp()), } # Check the logging. Regression test for a bug where new expirations # would be logged as raw datetime objects instead of timestamps. assert parse_log(caplog) == [ { "expires": int(new_expires.timestamp()), "event": "Modified token", "httpRequest": { "requestMethod": "PATCH", "requestUrl": f"https://{TEST_HOSTNAME}{token_url}", "remoteIp": "127.0.0.1", }, "key": user_token.key, "scope": "exec:admin read:all user:token", "severity": "info", "token": session_token.key, "token_name": "happy token", "token_scope": "exec:admin", "token_source": "cookie", "user": "******", } ] # Delete the token. r = await client.delete(token_url, headers={"X-CSRF-Token": csrf}) assert r.status_code == 204 r = await client.get(token_url) assert r.status_code == 404 # Deleting again should return 404. r = await client.delete(token_url, headers={"X-CSRF-Token": csrf}) assert r.status_code == 404 # This user should now have only one token. r = await client.get("/auth/api/v1/users/example/tokens") assert r.status_code == 200 assert len(r.json()) == 1 # We should be able to see the change history for the token. r = await client.get(token_url + "/change-history") assert r.status_code == 200 assert r.json() == [ { "token": user_token.key, "username": "******", "token_type": "user", "token_name": "happy token", "scopes": ["exec:admin"], "expires": int(new_expires.timestamp()), "actor": "example", "action": "revoke", "ip_address": "127.0.0.1", "event_time": ANY, }, { "token": user_token.key, "username": "******", "token_type": "user", "token_name": "happy token", "scopes": ["exec:admin"], "expires": int(new_expires.timestamp()), "actor": "example", "action": "edit", "old_token_name": "some token", "old_scopes": ["read:all"], "old_expires": int(expires.timestamp()), "ip_address": "127.0.0.1", "event_time": ANY, }, { "token": user_token.key, "username": "******", "token_type": "user", "token_name": "some token", "scopes": ["read:all"], "expires": int(expires.timestamp()), "actor": "example", "action": "create", "ip_address": "127.0.0.1", "event_time": ANY, }, ]
async def test_expiration(config: Config, factory: ComponentFactory) -> None: """The cache is valid until half the lifetime of the child token.""" token_data = await create_session_token(factory, scopes=["read:all"]) lifetime = config.token_lifetime now = current_datetime() redis = await redis_dependency() logger = structlog.get_logger(config.safir.logger_name) storage = RedisStorage(TokenData, config.session_secret, redis) token_store = TokenRedisStore(storage, logger) token_cache = factory.create_token_cache_service() # Store a token whose expiration is five seconds more than half the # typical token lifetime in the future and cache that token as an internal # token for our session token. created = now - timedelta(seconds=lifetime.total_seconds() // 2) expires = created + lifetime + timedelta(seconds=5) internal_token_data = TokenData( token=Token(), username=token_data.username, token_type=TokenType.internal, scopes=["read:all"], created=created, expires=expires, ) await token_store.store_data(internal_token_data) token_cache.store_internal_token(internal_token_data.token, token_data, "some-service", ["read:all"]) # The cache should return this token. assert internal_token_data.token == await token_cache.get_internal_token( token_data, "some-service", ["read:all"], "127.0.0.1") # Now change the expiration to be ten seconds earlier, which should make # the remaining lifetime less than half the total lifetime, and replace # replace the stored token with that new version. internal_token_data.expires = expires - timedelta(seconds=20) await token_store.store_data(internal_token_data) # The cache should now decline to return the token and generate a new one. old_token = internal_token_data.token async with factory.session.begin(): assert old_token != await token_cache.get_internal_token( token_data, "some-service", ["read:all"], "127.0.0.1") # Do the same test with a notebook token. notebook_token_data = TokenData( token=Token(), username=token_data.username, token_type=TokenType.notebook, scopes=["read:all"], created=created, expires=expires, ) await token_store.store_data(notebook_token_data) token_cache.store_notebook_token(notebook_token_data.token, token_data) assert notebook_token_data.token == await token_cache.get_notebook_token( token_data, "127.0.0.1") notebook_token_data.expires = expires - timedelta(seconds=20) await token_store.store_data(notebook_token_data) old_token = notebook_token_data.token async with factory.session.begin(): assert old_token != await token_cache.get_notebook_token( token_data, "127.0.0.1")
async def get_notebook_token(self, token_data: TokenData, ip_address: str) -> Token: """Get or create a new notebook token. The new token will have the same expiration time as the existing token on which it's based unless that expiration time is longer than the expiration time of normal interactive tokens, in which case it will be capped at the interactive token expiration time. Parameters ---------- token_data : `gafaelfawr.models.token.TokenData` The authentication data on which to base the new token. ip_address : `str` The IP address from which the request came. Returns ------- token : `gafaelfawr.models.token.Token` The newly-created token. Raises ------ gafaelfawr.exceptions.PermissionDeniedError If the username is invalid. """ self._validate_username(token_data.username) # See if there is a cached token. token = await self._token_cache.get_notebook_token(token_data) if token: return token # See if there's already a matching notebook token. key = self._token_db_store.get_notebook_token_key( token_data, self._minimum_expiration(token_data)) if key: data = await self._token_redis_store.get_data_by_key(key) if data: self._token_cache.store_notebook_token(data.token, token_data) return data.token # There is not, so we need to create a new one. token = Token() created = current_datetime() expires = created + self._config.token_lifetime if token_data.expires and token_data.expires < expires: expires = token_data.expires data = TokenData( token=token, username=token_data.username, token_type=TokenType.notebook, scopes=token_data.scopes, created=created, expires=expires, name=token_data.name, email=token_data.email, uid=token_data.uid, groups=token_data.groups, ) history_entry = TokenChangeHistoryEntry( token=token.key, username=data.username, token_type=TokenType.notebook, parent=token_data.token.key, scopes=data.scopes, expires=expires, actor=token_data.username, action=TokenChange.create, ip_address=ip_address, event_time=created, ) await self._token_redis_store.store_data(data) with self._transaction_manager.transaction(): self._token_db_store.add(data, parent=token_data.token.key) self._token_change_store.add(history_entry) # Cache the token and return it. self._logger.info("Created new notebook token", key=token.key) self._token_cache.store_notebook_token(token, token_data) return token
async def create_token_from_admin_request( self, request: AdminTokenRequest, auth_data: TokenData, *, ip_address: Optional[str], ) -> Token: """Create a new service or user token from an admin request. Parameters ---------- request : `gafaelfawr.models.token.AdminTokenRequest` The incoming request. auth_data : `gafaelfawr.models.token.TokenData` The data for the authenticated user making the request. ip_address : `str` or `None` The IP address from which the request came, or `None` for internal requests by Gafaelfawr. Returns ------- token : `gafaelfawr.models.token.Token` The newly-created token. Raises ------ gafaelfawr.exceptions.PermissionDeniedError If the provided username is invalid. """ self._check_authorization(request.username, auth_data, require_admin=True) self._validate_username(request.username) self._validate_scopes(request.scopes) self._validate_expires(request.expires) token = Token() created = current_datetime() data = TokenData( token=token, username=request.username, token_type=request.token_type, scopes=request.scopes, created=created, expires=request.expires, name=request.name, email=request.email, uid=request.uid, groups=request.groups, ) history_entry = TokenChangeHistoryEntry( token=token.key, username=data.username, token_type=data.token_type, token_name=request.token_name, scopes=data.scopes, expires=request.expires, actor=auth_data.username, action=TokenChange.create, ip_address=ip_address, event_time=created, ) await self._token_redis_store.store_data(data) with self._transaction_manager.transaction(): self._token_db_store.add(data, token_name=request.token_name) self._token_change_store.add(history_entry) if data.token_type == TokenType.user: self._logger.info( "Created new user token", key=token.key, token_name=request.token_name, token_scope=",".join(data.scopes), token_username=data.username, ) else: self._logger.info( "Created new service token", key=token.key, token_scope=",".join(data.scopes), token_username=data.username, ) return token
async def create_user_token( self, auth_data: TokenData, username: str, *, token_name: str, scopes: List[str], expires: Optional[datetime] = None, ip_address: str, ) -> Token: """Add a new user token. Parameters ---------- auth_data : `gafaelfawr.models.token.TokenData` The token data for the authentication token of the user creating a user token. username : `str` The username for which to create a token. token_name : `str` The name of the token. scopes : List[`str`] The scopes of the token. expires : `datetime` or `None` When the token should expire. If not given, defaults to the expiration of the authentication token taken from ``data``. ip_address : `str` The IP address from which the request came. Returns ------- token : `gafaelfawr.models.token.Token` The newly-created token. Raises ------ gafaelfawr.exceptions.DuplicateTokenNameError A token with this name for this user already exists. gafaelfawr.exceptions.InvalidExpiresError The provided expiration time was invalid. gafaelfawr.exceptions.PermissionDeniedError If the given username didn't match the user information in the authentication token, or if the specified username is invalid. Notes ----- This can only be used by the user themselves, not by a token administrator, because this API does not provide a way to set the additional user information for the token. Once the user information no longer needs to be tracked by the token system, it can be unified with ``create_token_from_admin_request``. """ self._check_authorization(username, auth_data, require_same_user=True) self._validate_username(username) self._validate_expires(expires) self._validate_scopes(scopes, auth_data) scopes = sorted(scopes) token = Token() created = current_datetime() data = TokenData( token=token, username=username, token_type=TokenType.user, scopes=scopes, created=created, expires=expires, name=auth_data.name, email=auth_data.email, uid=auth_data.uid, groups=auth_data.groups, ) history_entry = TokenChangeHistoryEntry( token=token.key, username=data.username, token_type=TokenType.user, token_name=token_name, scopes=scopes, expires=expires, actor=auth_data.username, action=TokenChange.create, ip_address=ip_address, event_time=created, ) await self._token_redis_store.store_data(data) with self._transaction_manager.transaction(): self._token_db_store.add(data, token_name=token_name) self._token_change_store.add(history_entry) self._logger.info( "Created new user token", key=token.key, token_name=token_name, token_scope=",".join(data.scopes), ) return token