diff --git a/docs/source/changelog.md b/docs/source/changelog.md index da9825f1..fd846bf7 100644 --- a/docs/source/changelog.md +++ b/docs/source/changelog.md @@ -6,6 +6,79 @@ command line for details. ## [Unreleased] +## 1.4 + +JupyterHub 1.4 is a small release, with several enhancements, bug fixes, +and new configuration options. + +There are no database schema changes requiring migration from 1.3 to 1.4. + +In particular, OAuth tokens stored in user cookies, +used for accessing single-user servers and hub-authenticated services, +have changed their expiration from one hour to the expiry of the cookie +in which they are stored (default: two weeks). +This is now also configurable via `JupyterHub.oauth_token_expires_in`. + +The result is that it should be much less likely for auth tokens stored in cookies +to expire during the lifetime of a server. + +### 1.4.0 + +([full changelog](https://github.com/jupyterhub/jupyterhub/compare/1.3.0...6121411aec529bac40f2535591ae36d1fc0e8df0)) + +#### New features added + +- make oauth token expiry configurable [#3411](https://github.com/jupyterhub/jupyterhub/pull/3411) ([@minrk](https://github.com/minrk)) +- Allow customization of service menu via templates [#3345](https://github.com/jupyterhub/jupyterhub/pull/3345) ([@stv0g](https://github.com/stv0g)) +- Add Spawner.delete_forever [#3337](https://github.com/jupyterhub/jupyterhub/pull/3337) ([@nsshah1288](https://github.com/nsshah1288)) +- Allow to set spawner-specific hub connect URL [#3326](https://github.com/jupyterhub/jupyterhub/pull/3326) ([@dtaniwaki](https://github.com/dtaniwaki)) +- Make Authenticator Custom HTML Flexible [#3315](https://github.com/jupyterhub/jupyterhub/pull/3315) ([@dtaniwaki](https://github.com/dtaniwaki)) + +#### Enhancements made + +- Don't delete all oauth clients on startup [#3407](https://github.com/jupyterhub/jupyterhub/pull/3407) ([@yuvipanda](https://github.com/yuvipanda)) +- Use 'secrets' module to generate secrets [#3394](https://github.com/jupyterhub/jupyterhub/pull/3394) ([@yuvipanda](https://github.com/yuvipanda)) +- Allow cookie_secret to be set to a hexadecimal string [#3343](https://github.com/jupyterhub/jupyterhub/pull/3343) ([@consideRatio](https://github.com/consideRatio)) +- Clear tornado xsrf cookie on logout [#3341](https://github.com/jupyterhub/jupyterhub/pull/3341) ([@dtaniwaki](https://github.com/dtaniwaki)) +- always log slow requests at least at info-level [#3338](https://github.com/jupyterhub/jupyterhub/pull/3338) ([@minrk](https://github.com/minrk)) + +#### Bugs fixed + +- always start redirect count at 1 when redirecting /hub/user/:name -> /user/:name [#3377](https://github.com/jupyterhub/jupyterhub/pull/3377) ([@minrk](https://github.com/minrk)) +- Always raise on failed token creation [#3370](https://github.com/jupyterhub/jupyterhub/pull/3370) ([@minrk](https://github.com/minrk)) +- make_singleuser_app: patch-in HubAuthenticatedHandler at lower priority [#3347](https://github.com/jupyterhub/jupyterhub/pull/3347) ([@minrk](https://github.com/minrk)) +- Fix pagination with named servers [#3335](https://github.com/jupyterhub/jupyterhub/pull/3335) ([@rcthomas](https://github.com/rcthomas)) + +#### Maintenance and upkeep improvements + +- avoid deprecated engine.table_names [#3392](https://github.com/jupyterhub/jupyterhub/pull/3392) ([@minrk](https://github.com/minrk)) +- alpine dockerfile: avoid compilation by getting some deps from apk [#3386](https://github.com/jupyterhub/jupyterhub/pull/3386) ([@minrk](https://github.com/minrk)) +- Fix sqlachemy.interfaces.PoolListener deprecation for tests [#3383](https://github.com/jupyterhub/jupyterhub/pull/3383) ([@IvanaH8](https://github.com/IvanaH8)) +- Update pre-commit hooks versions [#3362](https://github.com/jupyterhub/jupyterhub/pull/3362) ([@consideRatio](https://github.com/consideRatio)) +- add (and run) prettier pre-commit hook [#3360](https://github.com/jupyterhub/jupyterhub/pull/3360) ([@minrk](https://github.com/minrk)) +- move get_custom_html to base Authenticator class [#3359](https://github.com/jupyterhub/jupyterhub/pull/3359) ([@minrk](https://github.com/minrk)) +- publish release outputs as artifacts [#3349](https://github.com/jupyterhub/jupyterhub/pull/3349) ([@minrk](https://github.com/minrk)) +- [TST] Do not implicitly create users in auth_header [#3344](https://github.com/jupyterhub/jupyterhub/pull/3344) ([@minrk](https://github.com/minrk)) +- specify minimum alembic 1.4 [#3339](https://github.com/jupyterhub/jupyterhub/pull/3339) ([@minrk](https://github.com/minrk)) +- ci: github actions, allow for manual test runs and fix badge in readme [#3324](https://github.com/jupyterhub/jupyterhub/pull/3324) ([@consideRatio](https://github.com/consideRatio)) +- publish releases from github actions [#3305](https://github.com/jupyterhub/jupyterhub/pull/3305) ([@minrk](https://github.com/minrk)) + +#### Documentation improvements + +- Added Azure AD as a supported authenticator. [#3401](https://github.com/jupyterhub/jupyterhub/pull/3401) ([@maxshowarth](https://github.com/maxshowarth)) +- Remove the hard way guide [#3375](https://github.com/jupyterhub/jupyterhub/pull/3375) ([@manics](https://github.com/manics)) +- :memo: Fix telemetry section [#3333](https://github.com/jupyterhub/jupyterhub/pull/3333) ([@trallard](https://github.com/trallard)) +- Fix the help related to the proxy check [#3332](https://github.com/jupyterhub/jupyterhub/pull/3332) ([@jiajunjie](https://github.com/jiajunjie)) +- Mention Jupyter Server as optional single-user backend in documentation [#3329](https://github.com/jupyterhub/jupyterhub/pull/3329) ([@Zsailer](https://github.com/Zsailer)) +- Fix mixup in comment regarding the sync parameter [#3325](https://github.com/jupyterhub/jupyterhub/pull/3325) ([@andrewisplinghoff](https://github.com/andrewisplinghoff)) +- docs: fix simple typo, funciton -> function [#3314](https://github.com/jupyterhub/jupyterhub/pull/3314) ([@timgates42](https://github.com/timgates42)) + +#### Contributors to this release + +([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2020-12-11&to=2021-04-12&type=c)) + +[@00Kai0](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3A00Kai0+updated%3A2020-12-11..2021-04-12&type=Issues) | [@0mar](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3A0mar+updated%3A2020-12-11..2021-04-12&type=Issues) | [@8rV1n](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3A8rV1n+updated%3A2020-12-11..2021-04-12&type=Issues) | [@akhilputhiry](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aakhilputhiry+updated%3A2020-12-11..2021-04-12&type=Issues) | [@alexal](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aalexal+updated%3A2020-12-11..2021-04-12&type=Issues) | [@analytically](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aanalytically+updated%3A2020-12-11..2021-04-12&type=Issues) | [@andreamazzoni](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aandreamazzoni+updated%3A2020-12-11..2021-04-12&type=Issues) | [@andrewisplinghoff](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aandrewisplinghoff+updated%3A2020-12-11..2021-04-12&type=Issues) | [@BertR](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ABertR+updated%3A2020-12-11..2021-04-12&type=Issues) | [@betatim](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Abetatim+updated%3A2020-12-11..2021-04-12&type=Issues) | [@bitnik](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Abitnik+updated%3A2020-12-11..2021-04-12&type=Issues) | [@bollwyvl](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Abollwyvl+updated%3A2020-12-11..2021-04-12&type=Issues) | [@carluri](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Acarluri+updated%3A2020-12-11..2021-04-12&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2020-12-11..2021-04-12&type=Issues) | [@davidedelvento](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adavidedelvento+updated%3A2020-12-11..2021-04-12&type=Issues) | [@dhirschfeld](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adhirschfeld+updated%3A2020-12-11..2021-04-12&type=Issues) | [@dmpe](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Admpe+updated%3A2020-12-11..2021-04-12&type=Issues) | [@dsblank](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adsblank+updated%3A2020-12-11..2021-04-12&type=Issues) | [@dtaniwaki](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adtaniwaki+updated%3A2020-12-11..2021-04-12&type=Issues) | [@elgalu](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aelgalu+updated%3A2020-12-11..2021-04-12&type=Issues) | [@eran-pinhas](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aeran-pinhas+updated%3A2020-12-11..2021-04-12&type=Issues) | [@gaebor](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Agaebor+updated%3A2020-12-11..2021-04-12&type=Issues) | [@GeorgianaElena](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AGeorgianaElena+updated%3A2020-12-11..2021-04-12&type=Issues) | [@gsemet](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Agsemet+updated%3A2020-12-11..2021-04-12&type=Issues) | [@gweis](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Agweis+updated%3A2020-12-11..2021-04-12&type=Issues) | [@hynek2001](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ahynek2001+updated%3A2020-12-11..2021-04-12&type=Issues) | [@ianabc](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aianabc+updated%3A2020-12-11..2021-04-12&type=Issues) | [@ibre5041](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aibre5041+updated%3A2020-12-11..2021-04-12&type=Issues) | [@IvanaH8](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AIvanaH8+updated%3A2020-12-11..2021-04-12&type=Issues) | [@jhegedus42](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajhegedus42+updated%3A2020-12-11..2021-04-12&type=Issues) | [@jhermann](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajhermann+updated%3A2020-12-11..2021-04-12&type=Issues) | [@jiajunjie](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajiajunjie+updated%3A2020-12-11..2021-04-12&type=Issues) | [@jtlz2](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajtlz2+updated%3A2020-12-11..2021-04-12&type=Issues) | [@katsar0v](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Akatsar0v+updated%3A2020-12-11..2021-04-12&type=Issues) | [@kinow](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Akinow+updated%3A2020-12-11..2021-04-12&type=Issues) | [@krinsman](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Akrinsman+updated%3A2020-12-11..2021-04-12&type=Issues) | [@laurensdv](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Alaurensdv+updated%3A2020-12-11..2021-04-12&type=Issues) | [@lits789](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Alits789+updated%3A2020-12-11..2021-04-12&type=Issues) | [@m-alekseev](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Am-alekseev+updated%3A2020-12-11..2021-04-12&type=Issues) | [@mabbasi90](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amabbasi90+updated%3A2020-12-11..2021-04-12&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2020-12-11..2021-04-12&type=Issues) | [@manniche](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanniche+updated%3A2020-12-11..2021-04-12&type=Issues) | [@maxshowarth](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amaxshowarth+updated%3A2020-12-11..2021-04-12&type=Issues) | [@mdivk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amdivk+updated%3A2020-12-11..2021-04-12&type=Issues) | [@meeseeksmachine](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ameeseeksmachine+updated%3A2020-12-11..2021-04-12&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2020-12-11..2021-04-12&type=Issues) | [@mogthesprog](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amogthesprog+updated%3A2020-12-11..2021-04-12&type=Issues) | [@mriedem](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amriedem+updated%3A2020-12-11..2021-04-12&type=Issues) | [@nsshah1288](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ansshah1288+updated%3A2020-12-11..2021-04-12&type=Issues) | [@PandaWhoCodes](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3APandaWhoCodes+updated%3A2020-12-11..2021-04-12&type=Issues) | [@pawsaw](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Apawsaw+updated%3A2020-12-11..2021-04-12&type=Issues) | [@phozzy](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aphozzy+updated%3A2020-12-11..2021-04-12&type=Issues) | [@playermanny2](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aplayermanny2+updated%3A2020-12-11..2021-04-12&type=Issues) | [@rabsr](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arabsr+updated%3A2020-12-11..2021-04-12&type=Issues) | [@randy3k](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arandy3k+updated%3A2020-12-11..2021-04-12&type=Issues) | [@rawrgulmuffins](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arawrgulmuffins+updated%3A2020-12-11..2021-04-12&type=Issues) | [@rcthomas](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arcthomas+updated%3A2020-12-11..2021-04-12&type=Issues) | [@rebeca-maia](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arebeca-maia+updated%3A2020-12-11..2021-04-12&type=Issues) | [@rebenkoy](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arebenkoy+updated%3A2020-12-11..2021-04-12&type=Issues) | [@rkdarst](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arkdarst+updated%3A2020-12-11..2021-04-12&type=Issues) | [@robnagler](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arobnagler+updated%3A2020-12-11..2021-04-12&type=Issues) | [@ronaldpetty](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aronaldpetty+updated%3A2020-12-11..2021-04-12&type=Issues) | [@ryanlovett](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aryanlovett+updated%3A2020-12-11..2021-04-12&type=Issues) | [@ryogesh](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aryogesh+updated%3A2020-12-11..2021-04-12&type=Issues) | [@sbailey-auro](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asbailey-auro+updated%3A2020-12-11..2021-04-12&type=Issues) | [@sigurdurb](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asigurdurb+updated%3A2020-12-11..2021-04-12&type=Issues) | [@SivaAccionLabs](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ASivaAccionLabs+updated%3A2020-12-11..2021-04-12&type=Issues) | [@sougou](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asougou+updated%3A2020-12-11..2021-04-12&type=Issues) | [@stv0g](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Astv0g+updated%3A2020-12-11..2021-04-12&type=Issues) | [@sudi007](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asudi007+updated%3A2020-12-11..2021-04-12&type=Issues) | [@support](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asupport+updated%3A2020-12-11..2021-04-12&type=Issues) | [@tathagata](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atathagata+updated%3A2020-12-11..2021-04-12&type=Issues) | [@timgates42](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atimgates42+updated%3A2020-12-11..2021-04-12&type=Issues) | [@trallard](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atrallard+updated%3A2020-12-11..2021-04-12&type=Issues) | [@vlizanae](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Avlizanae+updated%3A2020-12-11..2021-04-12&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Awelcome+updated%3A2020-12-11..2021-04-12&type=Issues) | [@whitespaceninja](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Awhitespaceninja+updated%3A2020-12-11..2021-04-12&type=Issues) | [@whlteXbread](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AwhlteXbread+updated%3A2020-12-11..2021-04-12&type=Issues) | [@willingc](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Awillingc+updated%3A2020-12-11..2021-04-12&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ayuvipanda+updated%3A2020-12-11..2021-04-12&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AZsailer+updated%3A2020-12-11..2021-04-12&type=Issues) + ### 1.3 JupyterHub 1.3 is a small feature release. Highlights include: diff --git a/docs/source/getting-started/authenticators-users-basics.md b/docs/source/getting-started/authenticators-users-basics.md index 6b59231d..266ebde2 100644 --- a/docs/source/getting-started/authenticators-users-basics.md +++ b/docs/source/getting-started/authenticators-users-basics.md @@ -91,6 +91,7 @@ JupyterHub's [OAuthenticator][] currently supports the following popular services: - Auth0 +- Azure AD - Bitbucket - CILogon - GitHub diff --git a/docs/source/installation-guide-hard.rst b/docs/source/installation-guide-hard.rst index ff9b9c05..99569d3f 100644 --- a/docs/source/installation-guide-hard.rst +++ b/docs/source/installation-guide-hard.rst @@ -3,4 +3,4 @@ JupyterHub the hard way ======================= -This guide has moved to https://github.com/manics/jupyterhub-the-hard-way/blob/jupyterhub-alternative-doc/docs/installation-guide-hard.md +This guide has moved to https://github.com/jupyterhub/jupyterhub-the-hard-way/blob/master/docs/installation-guide-hard.md diff --git a/examples/service-fastapi/Dockerfile b/examples/service-fastapi/Dockerfile new file mode 100644 index 00000000..d2e8d5b5 --- /dev/null +++ b/examples/service-fastapi/Dockerfile @@ -0,0 +1,13 @@ +FROM jupyterhub/jupyterhub + +# Create test user (PAM auth) and install single-user Jupyter +RUN useradd testuser --create-home --shell /bin/bash +RUN echo 'testuser:passwd' | chpasswd +RUN pip install jupyter + +COPY app ./app +COPY jupyterhub_config.py . +COPY requirements.txt /tmp/requirements.txt +RUN pip install -r /tmp/requirements.txt + +CMD ["jupyterhub", "--ip", "0.0.0.0"] diff --git a/examples/service-fastapi/README.md b/examples/service-fastapi/README.md new file mode 100644 index 00000000..b2167228 --- /dev/null +++ b/examples/service-fastapi/README.md @@ -0,0 +1,107 @@ +# Fastapi + +[FastAPI](https://fastapi.tiangolo.com/) is a popular new web framework attractive for its type hinting, async support, automatic doc generation (Swagger), and more. Their [Feature highlights](https://fastapi.tiangolo.com/features/) sum it up nicely. + +# Swagger UI with OAuth demo + +![Fastapi Service Example](./fastapi_example.gif) + +# Try it out locally + +1. Install `fastapi` and other dependencies, then launch Jupyterhub + +``` +pip install -r requirements.txt +jupyterhub --ip=127.0.0.1 +``` + +2. Visit http://127.0.0.1:8000/services/fastapi or http://127.0.0.1:8000/services/fastapi/docs + +3. Try interacting programmatically. If you create a new token in your control panel or pull out the `JUPYTERHUB_API_TOKEN` in the single user environment, you can skip the third step here. + +``` +$ curl -X GET http://127.0.0.1:8000/services/fastapi/ +{"Hello":"World"} + +$ curl -X GET http://127.0.0.1:8000/services/fastapi/me +{"detail":"Must login with token parameter, cookie, or header"} + +$ curl -X POST http://127.0.0.1:8000/hub/api/authorizations/token \ + -d '{"username": "myname", "password": "mypasswd!"}' \ + | jq '.token' +"3fee13ce6d2845da9bd5f2c2170d3428" + +$ curl -X GET http://127.0.0.1:8000/services/fastapi/me \ + -H "Authorization: Bearer 3fee13ce6d2845da9bd5f2c2170d3428" \ + | jq . +{ + "name": "myname", + "admin": false, + "groups": [], + "server": null, + "pending": null, + "last_activity": "2021-04-07T18:05:11.587638+00:00", + "servers": null +} +``` + +# Try it out in Docker + +1. Build and run the Docker image locally + +```bash +sudo docker build . -t service-fastapi +sudo docker run -it -p 8000:8000 service-fastapi +``` + +2. Visit http://127.0.0.1:8000/services/fastapi/docs. When going through the OAuth flow or getting a token from the control panel, you can log in with `testuser` / `passwd`. + +# PUBLIC_HOST + +If you are running your service behind a proxy, or on a Docker / Kubernetes infrastructure, you might run into an error during OAuth that says `Mismatching redirect URI`. In the Jupterhub logs, there will be a warning along the lines of: `[W 2021-04-06 23:40:06.707 JupyterHub provider:498] Redirect uri https://jupyterhub.my.cloud/services/fastapi/oauth_callback != /services/fastapi/oauth_callback`. This happens because Swagger UI adds the request host, as seen in the browser, to the Authorization URL. + +To solve that problem, the `oauth_redirect_uri` value in the service initialization needs to match what Swagger will auto-generate and what the service will use when POST'ing to `/oauth2/token`. In this example, setting the `PUBLIC_HOST` environment variable to your public-facing Hub domain (e.g. `https://jupyterhub.my.cloud`) should make it work. + +# Notes on security.py + +FastAPI has a concept of a [dependency injection](https://fastapi.tiangolo.com/tutorial/dependencies) using a `Depends` object (and a subclass `Security`) that is automatically instantiated/executed when it is a parameter for your endpoint routes. You can utilize a `Depends` object for re-useable common parameters or authentication mechanisms like the [`get_user`](https://fastapi.tiangolo.com/tutorial/security/get-current-user) pattern. + +JupyterHub OAuth has three ways to authenticate: a `token` url parameter; a `Authorization: Bearer ` header; and a (deprecated) `jupyterhub-services` cookie. FastAPI has helper functions that let us create `Security` (dependency injection) objects for each of those. When you need to allow multiple / optional authentication dependencies (`Security` objects), then you can use the argument `auto_error=False` and it will return `None` instead of raising an `HTTPException`. + +Endpoints that need authentication (`/me` and `/debug` in this example) can leverage the `get_user` pattern and effectively pull the user model from the Hub API when a request has authenticated with cookie / token / header all using the simple syntax, + +```python +from .security import get_current_user +from .models import User + +@router.get("/new_endpoint") +async def new_endpoint(user: User = Depends(get_current_user)): + "Function that needs to work with an authenticated user" + return {"Hello": user.name} +``` + +# Notes on client.py + +FastAPI is designed to be an asynchronous web server, so the interactions with the Hub API should be made asynchronously as well. Instead of using `requests` to get user information from a token/cookie, this example uses [`httpx`](https://www.python-httpx.org/). `client.py` defines a small function that creates a `Client` (equivalent of `requests.Session`) with the Hub API url as it's `base_url` and adding the `JUPYTERHUB_API_TOKEN` to every header. + +Consider this a very minimal alternative to using `jupyterhub.services.auth.HubOAuth` + +```python +# client.py +import os + +def get_client(): + base_url = os.environ["JUPYTERHUB_API_URL"] + token = os.environ["JUPYTERHUB_API_TOKEN"] + headers = {"Authorization": "Bearer %s" % token} + return httpx.AsyncClient(base_url=base_url, headers=headers) +``` + +```python +# other modules +from .client import get_client + +async with get_client() as client: + resp = await client.get('/endpoint') + ... +``` diff --git a/examples/service-fastapi/app/__init__.py b/examples/service-fastapi/app/__init__.py new file mode 100644 index 00000000..c07c4599 --- /dev/null +++ b/examples/service-fastapi/app/__init__.py @@ -0,0 +1 @@ +from .app import app diff --git a/examples/service-fastapi/app/app.py b/examples/service-fastapi/app/app.py new file mode 100644 index 00000000..c586b63b --- /dev/null +++ b/examples/service-fastapi/app/app.py @@ -0,0 +1,25 @@ +import os + +from fastapi import FastAPI + +from .service import router + +### When managed by Jupyterhub, the actual endpoints +### will be served out prefixed by /services/:name. +### One way to handle this with FastAPI is to use an APIRouter. +### All routes are defined in service.py + +app = FastAPI( + title="Example FastAPI Service", + version="0.1", + ### Serve out Swagger from the service prefix (/services/:name/docs) + openapi_url=router.prefix + "/openapi.json", + docs_url=router.prefix + "/docs", + redoc_url=router.prefix + "/redoc", + ### Add our service client id to the /docs Authorize form automatically + swagger_ui_init_oauth={"clientId": os.environ["JUPYTERHUB_CLIENT_ID"]}, + ### Default /docs/oauth2 redirect will cause Hub + ### to raise oauth2 redirect uri mismatch errors + swagger_ui_oauth2_redirect_url=os.environ["JUPYTERHUB_OAUTH_CALLBACK_URL"], +) +app.include_router(router) diff --git a/examples/service-fastapi/app/client.py b/examples/service-fastapi/app/client.py new file mode 100644 index 00000000..e31d5ebc --- /dev/null +++ b/examples/service-fastapi/app/client.py @@ -0,0 +1,11 @@ +import os + +import httpx + + +# a minimal alternative to using HubOAuth class +def get_client(): + base_url = os.environ["JUPYTERHUB_API_URL"] + token = os.environ["JUPYTERHUB_API_TOKEN"] + headers = {"Authorization": "Bearer %s" % token} + return httpx.AsyncClient(base_url=base_url, headers=headers) diff --git a/examples/service-fastapi/app/models.py b/examples/service-fastapi/app/models.py new file mode 100644 index 00000000..c3751053 --- /dev/null +++ b/examples/service-fastapi/app/models.py @@ -0,0 +1,46 @@ +from datetime import datetime +from typing import Any +from typing import List +from typing import Optional + +from pydantic import BaseModel + + +# https://jupyterhub.readthedocs.io/en/stable/_static/rest-api/index.html +class Server(BaseModel): + name: str + ready: bool + pending: Optional[str] + url: str + progress_url: str + started: datetime + last_activity: datetime + state: Optional[Any] + user_options: Optional[Any] + + +class User(BaseModel): + name: str + admin: bool + groups: List[str] + server: Optional[str] + pending: Optional[str] + last_activity: datetime + servers: Optional[List[Server]] + + +# https://stackoverflow.com/questions/64501193/fastapi-how-to-use-httpexception-in-responses +class AuthorizationError(BaseModel): + detail: str + + +class HubResponse(BaseModel): + msg: str + request_url: str + token: str + response_code: int + hub_response: dict + + +class HubApiError(BaseModel): + detail: HubResponse diff --git a/examples/service-fastapi/app/security.py b/examples/service-fastapi/app/security.py new file mode 100644 index 00000000..0f1c79bd --- /dev/null +++ b/examples/service-fastapi/app/security.py @@ -0,0 +1,61 @@ +import os + +from fastapi import HTTPException +from fastapi import Security +from fastapi import status +from fastapi.security import OAuth2AuthorizationCodeBearer +from fastapi.security.api_key import APIKeyQuery + +from .client import get_client +from .models import User + +### Endpoints can require authentication using Depends(get_current_user) +### get_current_user will look for a token in url params or +### Authorization: bearer token (header). +### Hub technically supports cookie auth too, but it is deprecated so +### not being included here. +auth_by_param = APIKeyQuery(name="token", auto_error=False) + +auth_url = os.environ["PUBLIC_HOST"] + "/hub/api/oauth2/authorize" +auth_by_header = OAuth2AuthorizationCodeBearer( + authorizationUrl=auth_url, tokenUrl="get_token", auto_error=False +) +### ^^ The flow for OAuth2 in Swagger is that the "authorize" button +### will redirect user (browser) to "auth_url", which is the Hub login page. +### After logging in, the browser will POST to our internal /get_token endpoint +### with the auth code. That endpoint POST's to Hub /oauth2/token with +### our client_secret (JUPYTERHUB_API_TOKEN) and that code to get an +### access_token, which it returns to browser, which places in Authorization header. + +### For consideration: optimize performance with a cache instead of +### always hitting the Hub api? +async def get_current_user( + auth_by_param: str = Security(auth_by_param), + auth_by_header: str = Security(auth_by_header), +): + token = auth_by_param or auth_by_header + if token is None: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + detail="Must login with token parameter or Authorization bearer header", + ) + + async with get_client() as client: + endpoint = "/user" + # normally we auth to Hub API with service api token, + # but this time auth as the user token to get user model + headers = {"Authorization": f"Bearer {token}"} + resp = await client.get(endpoint, headers=headers) + if resp.is_error: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={ + "msg": "Error getting user info from token", + "request_url": str(resp.request.url), + "token": token, + "response_code": resp.status_code, + "hub_response": resp.json(), + }, + ) + user = User(**resp.json()) + return user diff --git a/examples/service-fastapi/app/service.py b/examples/service-fastapi/app/service.py new file mode 100644 index 00000000..da3f8545 --- /dev/null +++ b/examples/service-fastapi/app/service.py @@ -0,0 +1,70 @@ +import os + +from fastapi import APIRouter +from fastapi import Depends +from fastapi import Form +from fastapi import Request + +from .client import get_client +from .models import AuthorizationError +from .models import HubApiError +from .models import User +from .security import get_current_user + +# APIRouter prefix cannot end in / +service_prefix = os.getenv("JUPYTERHUB_SERVICE_PREFIX", "").rstrip("/") +router = APIRouter(prefix=service_prefix) + + +@router.post("/get_token", include_in_schema=False) +async def get_token(code: str = Form(...)): + "Callback function for OAuth2AuthorizationCodeBearer scheme" + # The only thing we need in this form post is the code + # Everything else we can hardcode / pull from env + async with get_client() as client: + redirect_uri = ( + os.environ["PUBLIC_HOST"] + os.environ["JUPYTERHUB_OAUTH_CALLBACK_URL"], + ) + data = { + "client_id": os.environ["JUPYTERHUB_CLIENT_ID"], + "client_secret": os.environ["JUPYTERHUB_API_TOKEN"], + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + } + resp = await client.post("/oauth2/token", data=data) + ### resp.json() is {'access_token': , 'token_type': 'Bearer'} + return resp.json() + + +@router.get("/") +async def index(): + "Non-authenticated function that returns {'Hello': 'World'}" + return {"Hello": "World"} + + +# response_model and responses dict translate to OpenAPI (Swagger) hints +# compare and contrast what the /me endpoint looks like in Swagger vs /debug +@router.get( + "/me", + response_model=User, + responses={401: {'model': AuthorizationError}, 400: {'model': HubApiError}}, +) +async def me(user: User = Depends(get_current_user)): + "Authenticated function that returns the User model" + return user + + +@router.get("/debug") +async def index(request: Request, user: User = Depends(get_current_user)): + """ + Authenticated function that returns a few pieces of debug + * Environ of the service process + * Request headers + * User model + """ + return { + "env": dict(os.environ), + "headers": dict(request.headers), + "user": user, + } diff --git a/examples/service-fastapi/fastapi_example.gif b/examples/service-fastapi/fastapi_example.gif new file mode 100644 index 00000000..6c2c2301 Binary files /dev/null and b/examples/service-fastapi/fastapi_example.gif differ diff --git a/examples/service-fastapi/jupyterhub_config.py b/examples/service-fastapi/jupyterhub_config.py new file mode 100644 index 00000000..9fb054ec --- /dev/null +++ b/examples/service-fastapi/jupyterhub_config.py @@ -0,0 +1,31 @@ +import os +import warnings + +# When Swagger performs OAuth2 in the browser, it will set +# the request host + relative path as the redirect uri, causing a +# uri mismatch if the oauth_redirect_uri is just the relative path +# is set in the c.JupyterHub.services entry (as per default). +# Therefore need to know the request host ahead of time. +if "PUBLIC_HOST" not in os.environ: + msg = ( + "env PUBLIC_HOST is not set, defaulting to http://127.0.0.1:8000. " + "This can cause problems with OAuth. " + "Set PUBLIC_HOST to your public (browser accessible) host." + ) + warnings.warn(msg) + public_host = "http://127.0.0.1:8000" +else: + public_host = os.environ["PUBLIC_HOST"].rstrip('/') +service_name = "fastapi" +oauth_redirect_uri = f"{public_host}/services/{service_name}/oauth_callback" + +c.JupyterHub.services = [ + { + "name": service_name, + "url": "http://127.0.0.1:10202", + "command": ["uvicorn", "app:app", "--port", "10202"], + "admin": True, + "oauth_redirect_uri": oauth_redirect_uri, + "environment": {"PUBLIC_HOST": public_host}, + } +] diff --git a/examples/service-fastapi/requirements.txt b/examples/service-fastapi/requirements.txt new file mode 100644 index 00000000..64716c79 --- /dev/null +++ b/examples/service-fastapi/requirements.txt @@ -0,0 +1,4 @@ +fastapi +httpx +python-multipart +uvicorn diff --git a/jupyterhub/alembic/versions/1cebaf56856c_session_id.py b/jupyterhub/alembic/versions/1cebaf56856c_session_id.py index abcad7ef..14c4d68b 100644 --- a/jupyterhub/alembic/versions/1cebaf56856c_session_id.py +++ b/jupyterhub/alembic/versions/1cebaf56856c_session_id.py @@ -23,7 +23,7 @@ tables = ('oauth_access_tokens', 'oauth_codes') def add_column_if_table_exists(table, column): engine = op.get_bind().engine - if table not in engine.table_names(): + if table not in sa.inspect(engine).get_table_names(): # table doesn't exist, no need to upgrade # because jupyterhub will create it on launch logger.warning("Skipping upgrade of absent table: %s", table) diff --git a/jupyterhub/alembic/versions/4dc2d5a8c53c_user_options.py b/jupyterhub/alembic/versions/4dc2d5a8c53c_user_options.py index d74e46b7..87417570 100644 --- a/jupyterhub/alembic/versions/4dc2d5a8c53c_user_options.py +++ b/jupyterhub/alembic/versions/4dc2d5a8c53c_user_options.py @@ -17,7 +17,8 @@ from jupyterhub.orm import JSONDict def upgrade(): - tables = op.get_bind().engine.table_names() + engine = op.get_bind().engine + tables = sa.inspect(engine).get_table_names() if 'spawners' in tables: op.add_column('spawners', sa.Column('user_options', JSONDict())) diff --git a/jupyterhub/alembic/versions/56cc5a70207e_token_tracking.py b/jupyterhub/alembic/versions/56cc5a70207e_token_tracking.py index 7583e6f2..6f2a2efa 100644 --- a/jupyterhub/alembic/versions/56cc5a70207e_token_tracking.py +++ b/jupyterhub/alembic/versions/56cc5a70207e_token_tracking.py @@ -20,7 +20,8 @@ logger = logging.getLogger('alembic') def upgrade(): - tables = op.get_bind().engine.table_names() + engine = op.get_bind().engine + tables = sa.inspect(engine).get_table_names() op.add_column('api_tokens', sa.Column('created', sa.DateTime(), nullable=True)) op.add_column( 'api_tokens', sa.Column('last_activity', sa.DateTime(), nullable=True) diff --git a/jupyterhub/alembic/versions/99a28a4418e1_user_created.py b/jupyterhub/alembic/versions/99a28a4418e1_user_created.py index e2746ebb..738ecbe3 100644 --- a/jupyterhub/alembic/versions/99a28a4418e1_user_created.py +++ b/jupyterhub/alembic/versions/99a28a4418e1_user_created.py @@ -31,7 +31,7 @@ def upgrade(): % (now,) ) - tables = c.engine.table_names() + tables = sa.inspect(c.engine).get_table_names() if 'spawners' in tables: op.add_column('spawners', sa.Column('started', sa.DateTime, nullable=True)) diff --git a/jupyterhub/alembic/versions/d68c98b66cd4_client_description.py b/jupyterhub/alembic/versions/d68c98b66cd4_client_description.py index 13c9cded..68a9b0ff 100644 --- a/jupyterhub/alembic/versions/d68c98b66cd4_client_description.py +++ b/jupyterhub/alembic/versions/d68c98b66cd4_client_description.py @@ -16,7 +16,8 @@ import sqlalchemy as sa def upgrade(): - tables = op.get_bind().engine.table_names() + engine = op.get_bind().engine + tables = sa.inspect(engine).get_table_names() if 'oauth_clients' in tables: op.add_column( 'oauth_clients', sa.Column('description', sa.Unicode(length=1023)) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index f4f7440d..a0c5fde0 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -9,6 +9,7 @@ import json import logging import os import re +import secrets import signal import socket import sys @@ -382,6 +383,42 @@ class JupyterHub(Application): Default is two weeks. """, ).tag(config=True) + + oauth_token_expires_in = Integer( + help="""Expiry (in seconds) of OAuth access tokens. + + The default is to expire when the cookie storing them expires, + according to `cookie_max_age_days` config. + + These are the tokens stored in cookies when you visit + a single-user server or service. + When they expire, you must re-authenticate with the Hub, + even if your Hub authentication is still valid. + If your Hub authentication is valid, + logging in may be a transparent redirect as you refresh the page. + + This does not affect JupyterHub API tokens in general, + which do not expire by default. + Only tokens issued during the oauth flow + accessing services and single-user servers are affected. + + .. versionadded:: 1.4 + OAuth token expires_in was not previously configurable. + .. versionchanged:: 1.4 + Default now uses cookie_max_age_days so that oauth tokens + which are generally stored in cookies, + expire when the cookies storing them expire. + Previously, it was one hour. + """, + config=True, + ) + + @default("oauth_token_expires_in") + def _cookie_max_age_seconds(self): + """default to cookie max age, where these tokens are stored""" + # convert cookie max age days to seconds + return int(self.cookie_max_age_days * 24 * 3600) + redirect_to_server = Bool( True, help="Redirect user to server (if running), instead of control panel." ).tag(config=True) @@ -1502,7 +1539,7 @@ class JupyterHub(Application): if not secret: secret_from = 'new' self.log.debug("Generating new %s", trait_name) - secret = os.urandom(COOKIE_SECRET_BYTES) + secret = secrets.token_bytes(COOKIE_SECRET_BYTES) if secret_file and secret_from == 'new': # if we generated a new secret, store it in the secret_file @@ -2253,6 +2290,7 @@ class JupyterHub(Application): lambda: self.db, url_prefix=url_path_join(base_url, 'api/oauth2'), login_url=url_path_join(base_url, 'login'), + token_expires_in=self.oauth_token_expires_in, ) def cleanup_oauth_clients(self): diff --git a/jupyterhub/oauth/provider.py b/jupyterhub/oauth/provider.py index ea275a45..7dd7b160 100644 --- a/jupyterhub/oauth/provider.py +++ b/jupyterhub/oauth/provider.py @@ -558,20 +558,25 @@ class JupyterHubOAuthServer(WebApplicationServer): hash its client_secret before putting it in the database. """ - # clear existing clients with same ID - for orm_client in self.db.query(orm.OAuthClient).filter_by( - identifier=client_id - ): - self.db.delete(orm_client) - self.db.commit() - - orm_client = orm.OAuthClient( - identifier=client_id, - secret=hash_token(client_secret), - redirect_uri=redirect_uri, - description=description, + # Update client if it already exists, else create it + # Sqlalchemy doesn't have a good db agnostic UPSERT, + # so we do this manually. It's protected inside a + # transaction, so should fail if there are multiple + # rows with the same identifier. + orm_client = ( + self.db.query(orm.OAuthClient).filter_by(identifier=client_id).one_or_none() ) - self.db.add(orm_client) + if orm_client is None: + orm_client = orm.OAuthClient( + identifier=client_id, + ) + self.db.add(orm_client) + app_log.info(f'Creating oauth client {client_id}') + else: + app_log.info(f'Updating oauth client {client_id}') + orm_client.secret = hash_token(client_secret) + orm_client.redirect_uri = redirect_uri + orm_client.description = description self.db.commit() def fetch_by_client_id(self, client_id): @@ -579,9 +584,9 @@ class JupyterHubOAuthServer(WebApplicationServer): return self.db.query(orm.OAuthClient).filter_by(identifier=client_id).first() -def make_provider(session_factory, url_prefix, login_url): +def make_provider(session_factory, url_prefix, login_url, **oauth_server_kwargs): """Make an OAuth provider""" db = session_factory() validator = JupyterHubRequestValidator(db) - server = JupyterHubOAuthServer(db, validator) + server = JupyterHubOAuthServer(db, validator, **oauth_server_kwargs) return server diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 7b344e66..6e6e8693 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -873,7 +873,7 @@ def check_db_revision(engine): - Empty databases are tagged with the current revision """ # Check database schema version - current_table_names = set(engine.table_names()) + current_table_names = set(inspect(engine).get_table_names()) my_table_names = set(Base.metadata.tables.keys()) from .dbutil import _temp_alembic_ini diff --git a/jupyterhub/singleuser/mixins.py b/jupyterhub/singleuser/mixins.py index d3eb2cf1..0e5bc06f 100755 --- a/jupyterhub/singleuser/mixins.py +++ b/jupyterhub/singleuser/mixins.py @@ -12,7 +12,9 @@ import asyncio import json import os import random +import secrets import warnings +from datetime import datetime from datetime import timezone from textwrap import dedent from urllib.parse import urlparse @@ -251,7 +253,7 @@ class SingleUserNotebookAppMixin(Configurable): cookie_secret = Bytes() def _cookie_secret_default(self): - return os.urandom(32) + return secrets.token_bytes(32) user = CUnicode().tag(config=True) group = CUnicode().tag(config=True) diff --git a/jupyterhub/tests/test_services_auth.py b/jupyterhub/tests/test_services_auth.py index d6160a63..b41d50db 100644 --- a/jupyterhub/tests/test_services_auth.py +++ b/jupyterhub/tests/test_services_auth.py @@ -475,6 +475,10 @@ async def test_oauth_logout(app, mockservice_url): session_id = s.cookies['jupyterhub-session-id'] assert len(auth_tokens()) == 1 + token = auth_tokens()[0] + assert token.expires_in is not None + # verify that oauth_token_expires_in has its desired effect + assert abs(app.oauth_token_expires_in - token.expires_in) < 30 # hit hub logout URL r = await s.get(public_url(app, path='hub/logout')) diff --git a/jupyterhub/user.py b/jupyterhub/user.py index bf8603f8..ce1dc300 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -826,10 +826,7 @@ class User: try: await maybe_future(spawner.run_post_stop_hook()) except: - spawner.clear_state() - spawner.orm_spawner.state = spawner.get_state() - self.db.commit() - raise + self.log.exception("Error in Spawner.post_stop_hook for %s", self) spawner.clear_state() spawner.orm_spawner.state = spawner.get_state() self.db.commit() diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index 9993ee37..d420c13c 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -8,6 +8,7 @@ import hashlib import inspect import os import random +import secrets import socket import ssl import sys @@ -325,7 +326,7 @@ def hash_token(token, salt=8, rounds=16384, algorithm='sha512'): """ h = hashlib.new(algorithm) if isinstance(salt, int): - salt = b2a_hex(os.urandom(salt)) + salt = b2a_hex(secrets.token_bytes(salt)) if isinstance(salt, bytes): bsalt = salt salt = salt.decode('utf8')