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_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 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 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 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 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_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 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()
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_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, }, ]
def test_generate_token() -> None: runner = CliRunner() result = runner.invoke(main, ["generate-token"]) assert result.exit_code == 0 assert Token.from_str(result.output.rstrip("\n"))
def from_file(cls, path: str) -> Config: """Construct a Config object from a settings file. Parameters ---------- path : `str` Path to the settings file in YAML. Returns ------- config : `Config` The corresponding Config object. """ with open(path, "r") as f: raw_settings = yaml.safe_load(f) settings = Settings.parse_obj(raw_settings) # Load the secrets from disk. key = cls._load_secret(settings.issuer.key_file) keypair = RSAKeyPair.from_pem(key) session_secret = cls._load_secret(settings.session_secret_file) redis_password = None if settings.redis_password_file: path = settings.redis_password_file redis_password = cls._load_secret(path).decode() influxdb_secret = None if settings.issuer.influxdb_secret_file: path = settings.issuer.influxdb_secret_file influxdb_secret = cls._load_secret(path).decode() if settings.github: path = settings.github.client_secret_file github_secret = cls._load_secret(path).decode() if settings.oidc: path = settings.oidc.client_secret_file oidc_secret = cls._load_secret(path).decode() # The database URL may have a separate secret in database_password, in # which case it needs to be added to the URL. database_url = settings.database_url if settings.database_password: parsed_url = urlparse(database_url) database_password = settings.database_password.get_secret_value() database_netloc = (f"{parsed_url.username}:{database_password}" f"@{parsed_url.hostname}") database_url = parsed_url._replace(netloc=database_netloc).geturl() # If there is an OpenID Connect server configuration, load it from a # file in JSON format. (It contains secrets.) oidc_server_config = None if settings.oidc_server_secrets_file: path = settings.oidc_server_secrets_file oidc_secrets_json = cls._load_secret(path).decode() oidc_secrets = json.loads(oidc_secrets_json) oidc_clients = tuple((OIDCClient(client_id=c["id"], client_secret=c["secret"]) for c in oidc_secrets)) oidc_server_config = OIDCServerConfig(clients=oidc_clients) # The group mapping in the settings maps a scope to a list of groups # that provide that scope. This may be conceptually easier for the # person writing the configuration, but for our purposes we want a map # from a group name to a set of scopes that group provides. # # Reconstruct the group mapping in the form in which we want to use it # internally. group_mapping = defaultdict(set) for scope, groups in settings.group_mapping.items(): for group in groups: group_mapping[group].add(scope) group_mapping_frozen = { k: frozenset(v) for k, v in group_mapping.items() } # Build the Config object. bootstrap_token = None if settings.bootstrap_token: bootstrap_token = Token.from_str(settings.bootstrap_token) issuer_config = IssuerConfig( iss=settings.issuer.iss, kid=settings.issuer.key_id, aud=settings.issuer.aud, keypair=keypair, exp_minutes=settings.issuer.exp_minutes, group_mapping=group_mapping_frozen, username_claim=settings.username_claim, uid_claim=settings.uid_claim, influxdb_secret=influxdb_secret, influxdb_username=settings.issuer.influxdb_username, ) verifier_config = VerifierConfig( iss=settings.issuer.iss, aud=settings.issuer.aud, keypair=keypair, username_claim=settings.username_claim, uid_claim=settings.uid_claim, oidc_iss=settings.oidc.issuer if settings.oidc else None, oidc_aud=settings.oidc.audience if settings.oidc else None, oidc_kids=tuple(settings.oidc.key_ids if settings.oidc else []), ) github_config = None if settings.github: github_config = GitHubConfig( client_id=settings.github.client_id, client_secret=github_secret, username_claim=settings.username_claim, uid_claim=settings.uid_claim, ) oidc_config = None if settings.oidc: oidc_config = OIDCConfig( client_id=settings.oidc.client_id, client_secret=oidc_secret, login_url=str(settings.oidc.login_url), login_params=settings.oidc.login_params, redirect_url=str(settings.oidc.redirect_url), token_url=str(settings.oidc.token_url), scopes=tuple(settings.oidc.scopes), issuer=settings.oidc.issuer, audience=settings.oidc.audience, key_ids=tuple(settings.oidc.key_ids), ) kubernetes_config = None if settings.kubernetes: kubernetes_config = KubernetesConfig( service_secrets=tuple(settings.kubernetes.service_secrets)) log_level = os.getenv("SAFIR_LOG_LEVEL", settings.loglevel) config = cls( realm=settings.realm, session_secret=session_secret.decode(), redis_url=settings.redis_url, redis_password=redis_password, bootstrap_token=bootstrap_token, proxies=tuple(settings.proxies if settings.proxies else []), after_logout_url=str(settings.after_logout_url), issuer=issuer_config, verifier=verifier_config, github=github_config, oidc=oidc_config, oidc_server=oidc_server_config, known_scopes=settings.known_scopes or {}, database_url=database_url, initial_admins=tuple(settings.initial_admins), token_lifetime=timedelta(minutes=settings.issuer.exp_minutes), kubernetes=kubernetes_config, safir=SafirConfig(log_level=log_level), ) # Configure logging. configure_logging( profile=config.safir.profile, log_level=config.safir.log_level, name=config.safir.logger_name, ) # Return the completed configuration. return config
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_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 authenticate(self, context: RequestContext, x_csrf_token: Optional[str] = None) -> TokenData: """Authenticate the request. Always check the user's cookie-based session first before checking the ``Authorization`` header because some applications (JupyterHub, for instance) may use the ``Authorization`` header for their own purposes. If the request was authenticated via a browser cookie rather than a provided ``Authorization`` header, and the method was something other than ``GET`` or ``OPTIONS``, require and verify the CSRF header as well. Parameters ---------- context : `gafaelfawr.dependencies.context.RequestContext` The request context. x_csrf_token : `str`, optional The value of the ``X-CSRF-Token`` header, if provided. Returns ------- data : `gafaelfawr.models.token.TokenData` The data associated with the verified token. Raises ------ fastapi.HTTPException If authentication is not provided or is not valid. """ token = context.state.token if token: context.rebind_logger(token_source="cookie") self._verify_csrf(context, x_csrf_token) elif not self.require_session: try: token_str = parse_authorization(context) if token_str: token = Token.from_str(token_str) except (InvalidRequestError, InvalidTokenError) as e: raise generate_challenge(context, self.auth_type, e) if not token: raise self._redirect_or_error(context) if self.allow_bootstrap_token: if token == context.config.bootstrap_token: bootstrap_data = TokenData.bootstrap_token() context.rebind_logger( token="<bootstrap>", user="******", scope=" ".join(sorted(bootstrap_data.scopes)), ) context.logger.info("Authenticated with bootstrap token") return bootstrap_data token_service = context.factory.create_token_service() data = await token_service.get_data(token) if not data: if context.state.token: raise self._redirect_or_error(context) else: exc = InvalidTokenError("Token is not valid") raise generate_challenge(context, self.auth_type, exc) context.rebind_logger( token=token.key, user=data.username, scope=" ".join(sorted(data.scopes)), ) if self.require_scope and self.require_scope not in data.scopes: msg = f"Token does not have required scope {self.require_scope}" context.logger.info("Permission denied", error=msg) raise PermissionDeniedError(msg) return data