def __init__(self, config: LDAPConfig) -> None: super().__init__(spec=bonsai.LDAPClient) self.config = config self.groups = [ TokenGroup(name="foo", id=1222), TokenGroup(name="group-1", id=123123), TokenGroup(name="group-2", id=123442), ] self.query: Optional[str] = None
async def test_invalid_username(factory: ComponentFactory) -> None: user_info = TokenUserInfo( username="******", name="Example Person", uid=4137, groups=[TokenGroup(name="foo", id=1000)], ) token_service = factory.create_token_service() async with factory.session.begin(): session_token = await token_service.create_session_token( user_info, scopes=["read:all", "admin:token"], ip_address="127.0.0.1", ) data = await token_service.get_data(session_token) assert data # Cannot create any type of token with an invalid name. for user in ( "<bootstrap>", "<internal>", "in+valid", " invalid", "invalid ", "in/valid", "in@valid", "-invalid", "invalid-", "in--valid", ): user_info.username = user with pytest.raises(PermissionDeniedError): await token_service.create_session_token(user_info, scopes=[], ip_address="127.0.0.1") data.username = user with pytest.raises(PermissionDeniedError): await token_service.create_user_token(data, user, token_name="n", scopes=[], ip_address="127.0.0.1") with pytest.raises(PermissionDeniedError): await token_service.get_notebook_token(data, ip_address="127.0.0.1") with pytest.raises(PermissionDeniedError): await token_service.get_internal_token(data, service="s", scopes=[], ip_address="127.0.0.1") with pytest.raises(ValidationError): AdminTokenRequest(username=user, token_type=TokenType.service) request = AdminTokenRequest(username="******", token_type=TokenType.service) request.username = user with pytest.raises(PermissionDeniedError): await token_service.create_token_from_admin_request( request, data, ip_address="127.0.0.1")
async def create_session_token( factory: ComponentFactory, *, username: Optional[str] = None, group_names: Optional[List[str]] = None, scopes: Optional[List[str]] = None, ) -> TokenData: """Create a session token. Parameters ---------- factory : `gafaelfawr.factory.ComponentFactory` Factory used to create services to add the token. username : `str`, optional Override the username of the generated token. group_namess : List[`str`], optional Group memberships the generated token should have. scopes : List[`str`], optional Scope for the generated token. Returns ------- data : `gafaelfawr.models.token.TokenData` The data for the generated token. """ if not username: username = "******" if group_names: groups = [TokenGroup(name=g, id=1000) for g in group_names] else: groups = [] user_info = TokenUserInfo( username=username, name="Some User", email="*****@*****.**", uid=1000, groups=groups, ) if not scopes: scopes = ["user:token"] token_service = factory.create_token_service() async with factory.session.begin(): token = await token_service.create_session_token( user_info, scopes=scopes, ip_address="127.0.0.1" ) data = await token_service.get_data(token) assert data return data
async def create_user_info(self, code: str, state: str) -> TokenUserInfo: """Given the code from an authentication, create the user information. Parameters ---------- code : `str` Code returned by a successful authentication. state : `str` The same random string used for the redirect URL. Returns ------- user_info : `gafaelfawr.models.token.TokenUserInfo` The user information corresponding to that authentication. Raises ------ httpx.HTTPError An HTTP client error occurred trying to talk to the authentication provider. gafaelfawr.exceptions.GitHubException GitHub responded with an error to a request. """ self._logger.info("Getting user information from GitHub") github_token = await self._get_access_token(code, state) user_info = await self._get_user_info(github_token) groups = [] invalid_groups = {} for team in user_info.teams: try: groups.append(TokenGroup(name=team.group_name, id=team.gid)) except ValidationError as e: invalid_groups[team.group_name] = str(e) if invalid_groups: self._logger.warning("Ignoring invalid groups", invalid_groups=invalid_groups) return TokenUserInfo( username=user_info.username.lower(), name=user_info.name, email=user_info.email, uid=user_info.uid, groups=groups, )
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_token_info(setup: SetupTest) -> 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=["exec:admin", "user:token"], ip_address="127.0.0.1") r = await setup.client.get( "/auth/api/v1/token-info", headers={"Authorization": f"bearer {session_token}"}, ) assert r.status_code == 200 data = r.json() assert data == { "token": session_token.key, "username": "******", "token_type": "session", "scopes": ["exec:admin", "user:token"], "created": ANY, "expires": ANY, } now = datetime.now(tz=timezone.utc) created = datetime.fromtimestamp(data["created"], tz=timezone.utc) assert now - timedelta(seconds=2) <= created <= now expires = created + timedelta(minutes=setup.config.issuer.exp_minutes) assert datetime.fromtimestamp(data["expires"], tz=timezone.utc) == expires r = await setup.client.get( "/auth/api/v1/user-info", headers={"Authorization": f"bearer {session_token}"}, ) assert r.status_code == 200 session_user_info = r.json() assert session_user_info == { "username": "******", "name": "Example Person", "email": "*****@*****.**", "uid": 45613, "groups": [{ "name": "foo", "id": 12313, }], } # Check the same with a user token, which has some additional associated # data. expires = now + timedelta(days=100) data = await token_service.get_data(session_token) user_token = await token_service.create_user_token( data, data.username, token_name="some-token", scopes=["exec:admin"], expires=expires, ip_address="127.0.0.1", ) r = await setup.client.get( "/auth/api/v1/token-info", headers={"Authorization": f"bearer {user_token}"}, ) assert r.status_code == 200 data = r.json() assert data == { "token": user_token.key, "username": "******", "token_type": "user", "token_name": "some-token", "scopes": ["exec:admin"], "created": ANY, "expires": int(expires.timestamp()), } r = await setup.client.get( "/auth/api/v1/user-info", headers={"Authorization": f"bearer {user_token}"}, ) assert r.status_code == 200 assert r.json() == session_user_info # Test getting a list of tokens for a user. state = State(token=session_token) r = await setup.client.get( "/auth/api/v1/users/example/tokens", cookies={COOKIE_NAME: state.as_cookie()}, )
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_token_from_admin_request(factory: ComponentFactory) -> None: user_info = TokenUserInfo(username="******", name="Example Person", uid=4137) token_service = factory.create_token_service() async with factory.session.begin(): token = await token_service.create_session_token( user_info, scopes=[], ip_address="127.0.0.1") data = await token_service.get_data(token) assert data expires = current_datetime() + timedelta(days=2) request = AdminTokenRequest( username="******", token_type=TokenType.user, token_name="some token", scopes=["read:all"], expires=expires, name="Other User", uid=1345, groups=[TokenGroup(name="some-group", id=4133)], ) # Cannot create a token via admin request because the authentication # information is missing the admin:token scope. with pytest.raises(PermissionDeniedError): await token_service.create_token_from_admin_request( request, data, ip_address="127.0.0.1") # Get a token with an appropriate scope. async with factory.session.begin(): session_token = await token_service.create_session_token( user_info, scopes=["admin:token"], ip_address="127.0.0.1") admin_data = await token_service.get_data(session_token) assert admin_data # Test a few more errors. request.scopes = ["bogus:scope"] with pytest.raises(InvalidScopesError): await token_service.create_token_from_admin_request( request, admin_data, ip_address="127.0.0.1") request.scopes = ["read:all"] request.expires = current_datetime() with pytest.raises(InvalidExpiresError): await token_service.create_token_from_admin_request( request, admin_data, ip_address="127.0.0.1") request.expires = expires # Try a successful request. async with factory.session.begin(): token = await token_service.create_token_from_admin_request( request, admin_data, ip_address="127.0.0.1") user_data = await token_service.get_data(token) assert user_data and user_data == TokenData( token=token, created=user_data.created, **request.dict()) assert_is_now(user_data.created) async with factory.session.begin(): history = await token_service.get_change_history( admin_data, token=token.key, username=request.username) assert history.entries == [ TokenChangeHistoryEntry( token=token.key, username=request.username, token_type=TokenType.user, token_name=request.token_name, scopes=["read:all"], expires=request.expires, actor=admin_data.username, action=TokenChange.create, ip_address="127.0.0.1", event_time=user_data.created, ) ] # Non-admins can't see other people's tokens. with pytest.raises(PermissionDeniedError): await token_service.get_change_history(data, token=token.key, username=request.username) # Now request a service token with minimal data instead. request = AdminTokenRequest(username="******", token_type=TokenType.service) async with factory.session.begin(): token = await token_service.create_token_from_admin_request( request, admin_data, ip_address="127.0.0.1") service_data = await token_service.get_data(token) assert service_data and service_data == TokenData( token=token, created=service_data.created, **request.dict()) assert_is_now(service_data.created) async with factory.session.begin(): history = await token_service.get_change_history( admin_data, token=token.key, username=request.username) assert history.entries == [ TokenChangeHistoryEntry( token=token.key, username=request.username, token_type=TokenType.service, scopes=[], expires=None, actor=admin_data.username, action=TokenChange.create, ip_address="127.0.0.1", event_time=service_data.created, ) ]
async def test_session_token(config: Config, factory: ComponentFactory) -> None: token_service = factory.create_token_service() user_info = TokenUserInfo( username="******", name="Example Person", uid=4137, groups=[ TokenGroup(name="group", id=1000), TokenGroup(name="another", id=3134), ], ) async with factory.session.begin(): token = await token_service.create_session_token( user_info, scopes=["user:token"], ip_address="127.0.0.1") data = await token_service.get_data(token) assert data and data == TokenData( token=token, username="******", token_type=TokenType.session, scopes=["user:token"], created=data.created, expires=data.expires, name="Example Person", uid=4137, groups=[ TokenGroup(name="group", id=1000), TokenGroup(name="another", id=3134), ], ) assert_is_now(data.created) expires = data.created + timedelta(minutes=config.issuer.exp_minutes) assert data.expires == expires async with factory.session.begin(): info = await token_service.get_token_info_unchecked(token.key) assert info and info == TokenInfo( token=token.key, username=user_info.username, token_name=None, token_type=TokenType.session, scopes=data.scopes, created=int(data.created.timestamp()), last_used=None, expires=int(data.expires.timestamp()), parent=None, ) assert await token_service.get_user_info(token) == user_info async with factory.session.begin(): history = await token_service.get_change_history( data, token=token.key, username=data.username) assert history.entries == [ TokenChangeHistoryEntry( token=token.key, username=data.username, token_type=TokenType.session, scopes=["user:token"], expires=data.expires, actor=data.username, action=TokenChange.create, ip_address="127.0.0.1", event_time=data.created, ) ] # Test a session token with scopes. async with factory.session.begin(): token = await token_service.create_session_token( user_info, scopes=["read:all", "exec:admin"], ip_address="127.0.0.1", ) data = await token_service.get_data(token) assert data and data.scopes == ["exec:admin", "read:all"] info = await token_service.get_token_info_unchecked(token.key) assert info and info.scopes == ["exec:admin", "read:all"]
async def test_internal_token(config: Config, factory: ComponentFactory) -> None: user_info = TokenUserInfo( username="******", name="Example Person", uid=4137, groups=[TokenGroup(name="foo", id=1000)], ) 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", ) data = await token_service.get_data(session_token) assert data async with factory.session.begin(): internal_token = await token_service.get_internal_token( data, service="some-service", scopes=["read:all"], ip_address="2001:db8::45", ) assert await token_service.get_user_info(internal_token) == user_info info = await token_service.get_token_info_unchecked(internal_token.key) assert info and info == TokenInfo( token=internal_token.key, username=user_info.username, token_type=TokenType.internal, service="some-service", scopes=["read:all"], created=info.created, last_used=None, expires=data.expires, parent=session_token.key, ) assert_is_now(info.created) # Cannot request a scope that the parent token doesn't have. with pytest.raises(InvalidScopesError): await token_service.get_internal_token( data, service="some-service", scopes=["read:some"], ip_address="127.0.0.1", ) # Creating another internal token from the same parent token with the same # parameters just returns the same internal token as before. new_internal_token = await token_service.get_internal_token( data, service="some-service", scopes=["read:all"], ip_address="127.0.0.1", ) assert internal_token == new_internal_token # Try again with the cache cleared to force a database lookup. await token_service._token_cache.clear() async with factory.session.begin(): new_internal_token = await token_service.get_internal_token( data, service="some-service", scopes=["read:all"], ip_address="127.0.0.1", ) assert internal_token == new_internal_token async with factory.session.begin(): history = await token_service.get_change_history( data, token=internal_token.key, username=data.username) assert history.entries == [ TokenChangeHistoryEntry( token=internal_token.key, username=data.username, token_type=TokenType.internal, parent=data.token.key, service="some-service", scopes=["read:all"], expires=data.expires, actor=data.username, action=TokenChange.create, ip_address="2001:db8::45", event_time=info.created, ) ] # It's possible we'll have a race condition where two workers both create # an internal token at the same time with the same parameters. Gafaelfawr # 3.0.2 had a regression where, once that had happened, it could not # retrieve the internal token because it didn't expect multiple results # from the query. Simulate this and make sure it's handled properly. The # easiest way to do this is to use the internals of the token service. second_internal_token = Token() created = current_datetime() expires = created + config.token_lifetime internal_token_data = TokenData( token=second_internal_token, username=data.username, token_type=TokenType.internal, scopes=["read:all"], created=created, expires=expires, name=data.name, email=data.email, uid=data.uid, groups=data.groups, ) await token_service._token_redis_store.store_data(internal_token_data) async with factory.session.begin(): await token_service._token_db_store.add(internal_token_data, service="some-service", parent=data.token.key) await token_service._token_cache.clear() async with factory.session.begin(): dup_internal_token = await token_service.get_internal_token( data, service="some-service", scopes=["read:all"], ip_address="127.0.0.1", ) assert dup_internal_token in (internal_token, second_internal_token) # A different scope or a different service results in a new token. async with factory.session.begin(): new_internal_token = await token_service.get_internal_token( data, service="some-service", scopes=["exec:admin"], ip_address="127.0.0.1", ) assert internal_token != new_internal_token async with factory.session.begin(): new_internal_token = await token_service.get_internal_token( data, service="another-service", scopes=["read:all"], ip_address="127.0.0.1", ) assert internal_token != new_internal_token # Check that the expiration time is capped by creating a user token that # doesn't expire and then creating a notebook token from it. Use this to # test a token with empty scopes. async with factory.session.begin(): user_token = await token_service.create_user_token( data, data.username, token_name="some token", scopes=["exec:admin"], expires=None, ip_address="127.0.0.1", ) data = await token_service.get_data(user_token) assert data async with factory.session.begin(): new_internal_token = await token_service.get_internal_token( data, service="some-service", scopes=[], ip_address="127.0.0.1") assert new_internal_token != internal_token info = await token_service.get_token_info_unchecked( new_internal_token.key) assert info and info.scopes == [] expires = info.created + timedelta(minutes=config.issuer.exp_minutes) assert info.expires == expires
async def test_internal_token(setup: SetupTest) -> None: user_info = TokenUserInfo( username="******", name="Example Person", uid=4137, groups=[TokenGroup(name="foo", id=1000)], ) 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", ) data = await token_service.get_data(session_token) assert data internal_token = await token_service.get_internal_token( data, service="some-service", scopes=["read:all"], ip_address="2001:db8::45", ) assert await token_service.get_user_info(internal_token) == user_info info = token_service.get_token_info_unchecked(internal_token.key) assert info and info == TokenInfo( token=internal_token.key, username=user_info.username, token_type=TokenType.internal, service="some-service", scopes=["read:all"], created=info.created, last_used=None, expires=data.expires, parent=session_token.key, ) assert_is_now(info.created) # Cannot request a scope that the parent token doesn't have. with pytest.raises(InvalidScopesError): await token_service.get_internal_token( data, service="some-service", scopes=["read:some"], ip_address="127.0.0.1", ) # Creating another internal token from the same parent token with the same # parameters just returns the same internal token as before. new_internal_token = await token_service.get_internal_token( data, service="some-service", scopes=["read:all"], ip_address="127.0.0.1", ) assert internal_token == new_internal_token history = token_service.get_change_history( data, token=internal_token.key, username=data.username ) assert history.entries == [ TokenChangeHistoryEntry( token=internal_token.key, username=data.username, token_type=TokenType.internal, parent=data.token.key, service="some-service", scopes=["read:all"], expires=data.expires, actor=data.username, action=TokenChange.create, ip_address="2001:db8::45", event_time=info.created, ) ] # A different scope or a different service results in a new token. new_internal_token = await token_service.get_internal_token( data, service="some-service", scopes=["exec:admin"], ip_address="127.0.0.1", ) assert internal_token != new_internal_token new_internal_token = await token_service.get_internal_token( data, service="another-service", scopes=["read:all"], ip_address="127.0.0.1", ) assert internal_token != new_internal_token # Check that the expiration time is capped by creating a user token that # doesn't expire and then creating a notebook token from it. Use this to # test a token with empty scopes. user_token = await token_service.create_user_token( data, data.username, token_name="some token", scopes=["exec:admin"], expires=None, ip_address="127.0.0.1", ) data = await token_service.get_data(user_token) assert data new_internal_token = await token_service.get_internal_token( data, service="some-service", scopes=[], ip_address="127.0.0.1" ) assert new_internal_token != internal_token info = token_service.get_token_info_unchecked(new_internal_token.key) assert info and info.scopes == [] expires = info.created + timedelta(minutes=setup.config.issuer.exp_minutes) assert info.expires == expires
async def test_notebook_token(setup: SetupTest) -> None: user_info = TokenUserInfo( username="******", name="Example Person", uid=4137, groups=[TokenGroup(name="foo", id=1000)], ) 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", ) data = await token_service.get_data(session_token) assert data token = await token_service.get_notebook_token(data, ip_address="1.0.0.1") assert await token_service.get_user_info(token) == user_info info = token_service.get_token_info_unchecked(token.key) assert info and info == TokenInfo( token=token.key, username=user_info.username, token_type=TokenType.notebook, scopes=["exec:admin", "read:all", "user:token"], created=info.created, last_used=None, expires=data.expires, parent=session_token.key, ) assert_is_now(info.created) assert await token_service.get_data(token) == TokenData( token=token, username=user_info.username, token_type=TokenType.notebook, scopes=["exec:admin", "read:all", "user:token"], created=info.created, expires=data.expires, name=user_info.name, uid=user_info.uid, groups=user_info.groups, ) # Creating another notebook token from the same parent token just returns # the same notebook token as before. new_token = await token_service.get_notebook_token( data, ip_address="127.0.0.1" ) assert token == new_token history = token_service.get_change_history( data, token=token.key, username=data.username ) assert history.entries == [ TokenChangeHistoryEntry( token=token.key, username=data.username, token_type=TokenType.notebook, parent=data.token.key, scopes=["exec:admin", "read:all", "user:token"], expires=data.expires, actor=data.username, action=TokenChange.create, ip_address="1.0.0.1", event_time=info.created, ) ] # Check that the expiration time is capped by creating a user token that # doesn't expire and then creating a notebook token from it. user_token = await token_service.create_user_token( data, data.username, token_name="some token", scopes=[], expires=None, ip_address="127.0.0.1", ) data = await token_service.get_data(user_token) assert data new_token = await token_service.get_notebook_token( data, ip_address="127.0.0.1" ) assert new_token != token info = token_service.get_token_info_unchecked(new_token.key) assert info expires = info.created + timedelta(minutes=setup.config.issuer.exp_minutes) assert info.expires == expires
async def create_user_info(self, code: str, state: str) -> TokenUserInfo: """Given the code from a successful authentication, get a token. Parameters ---------- code : `str` Code returned by a successful authentication. state : `str` The same random string used for the redirect URL. Returns ------- user_info : `gafaelfawr.models.token.TokenUserInfo` The user information corresponding to that authentication. Raises ------ gafaelfawr.exceptions.OIDCException The OpenID Connect provider responded with an error to a request. httpx.HTTPError An HTTP client error occurred trying to talk to the authentication provider. jwt.exceptions.InvalidTokenError The token returned by the OpenID Connect provider was invalid. """ data = { "grant_type": "authorization_code", "client_id": self._config.client_id, "client_secret": self._config.client_secret, "code": code, "redirect_uri": self._config.redirect_url, } self._logger.info("Retrieving ID token from %s", self._config.token_url) r = await self._http_client.post( self._config.token_url, data=data, headers={"Accept": "application/json"}, ) # If the call failed, try to extract an error from the reply. If that # fails, just raise an exception for the HTTP status. try: result = r.json() except Exception: if r.status_code != 200: r.raise_for_status() else: msg = "Response from {self._config.token_url} not valid JSON" raise OIDCException(msg) if r.status_code != 200 and "error" in result: msg = result["error"] + ": " + result["error_description"] raise OIDCException(msg) elif r.status_code != 200: r.raise_for_status() if "id_token" not in result: msg = f"No id_token in token reply from {self._config.token_url}" raise OIDCException(msg) # Extract and verify the token. unverified_token = OIDCToken(encoded=result["id_token"]) try: token = await self._verifier.verify_oidc_token(unverified_token) except (jwt.InvalidTokenError, VerifyTokenException) as e: msg = f"OpenID Connect token verification failed: {str(e)}" raise OIDCException(msg) # Extract information from it to create the user information. groups = [] invalid_groups = {} try: for oidc_group in token.claims.get("isMemberOf", []): if "name" not in oidc_group: continue name = oidc_group["name"] if "id" not in oidc_group: invalid_groups[name] = "missing id" continue gid = int(oidc_group["id"]) try: groups.append(TokenGroup(name=name, id=gid)) except ValidationError as e: invalid_groups[name] = str(e) except Exception as e: msg = f"isMemberOf claim is invalid: {str(e)}" raise OIDCException(msg) return TokenUserInfo( username=token.username, name=token.claims.get("name"), email=token.claims.get("email"), uid=token.uid, groups=groups, )