Repository navigation
Add browser login to airflowctl #75938
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
baptistegh
wants to merge
3
commits into
apache:main
Choose a base branch
from
baptistegh:airflowctl-keycloak-sso-preview
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+832
−58
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| # | ||
| # Licensed to the Apache Software Foundation (ASF) under one | ||
| # or more contributor license agreements. See the NOTICE file | ||
| # distributed with this work for additional information | ||
| # regarding copyright ownership. The ASF licenses this file | ||
| # to you under the Apache License, Version 2.0 (the | ||
| # "License"); you may not use this file except in compliance | ||
| # with the License. You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, | ||
| # software distributed under the License is distributed on an | ||
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| # KIND, either express or implied. See the License for the | ||
| # specific language governing permissions and limitations | ||
| # under the License. | ||
| """Browser login with the authorization code flow and PKCE (RFC 7636), over a loopback redirect (RFC 8252).""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import base64 | ||
| import hashlib | ||
| import secrets | ||
| import time | ||
| import webbrowser | ||
| from dataclasses import dataclass | ||
| from http.server import BaseHTTPRequestHandler, HTTPServer | ||
| from urllib.parse import parse_qs, urlencode, urljoin, urlsplit | ||
|
|
||
| from rich.console import Console | ||
| from rich.markup import escape | ||
|
|
||
| from airflowctl.exceptions import AirflowCtlBrowserLoginException | ||
|
|
||
| CALLBACK_PATH = "/callback" | ||
| _CALLBACK_PAGE = ( | ||
| b"<!doctype html><title>airflowctl</title>" | ||
| b"<p>Login complete. You can close this window and return to the terminal.</p>" | ||
| ) | ||
| _CALLBACK_FAILED_PAGE = ( | ||
| b"<!doctype html><title>airflowctl</title><p>Login failed. Return to the terminal for details.</p>" | ||
| ) | ||
|
|
||
|
|
||
| @dataclass | ||
| class AuthorizationCode: | ||
| """Authorization code returned to the loopback redirect, with what the token endpoint needs to redeem it.""" | ||
|
|
||
| code: str | ||
| code_verifier: str | ||
| redirect_uri: str | ||
|
|
||
|
|
||
| def build_pkce_pair() -> tuple[str, str]: | ||
| """Return a PKCE ``(code_verifier, code_challenge)`` pair for the ``S256`` method.""" | ||
| code_verifier = secrets.token_urlsafe(64) | ||
| digest = hashlib.sha256(code_verifier.encode("ascii")).digest() | ||
| code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") | ||
| return code_verifier, code_challenge | ||
|
|
||
|
|
||
| def resolve_endpoint(api_url: str, endpoint: str) -> str: | ||
| """Return the URL of a login metadata endpoint, which must be on the API server.""" | ||
| url = urljoin(api_url, endpoint) | ||
| if urlsplit(url)[:2] != urlsplit(api_url)[:2]: | ||
| raise AirflowCtlBrowserLoginException(f"{endpoint} is not an endpoint of {api_url}.") | ||
| return url | ||
|
|
||
|
|
||
| def _find_login_error(params: dict[str, str], state: str) -> str | None: | ||
| """Return why the callback parameters do not complete this login, or ``None`` if they do.""" | ||
| # compare_digest raises TypeError on non-ASCII str. | ||
| if not secrets.compare_digest(params.get("state", "").encode(), state.encode()): | ||
| return "The login response does not match this login attempt." | ||
| if "error" in params: | ||
| return f"Login refused: {params.get('error_description') or params['error']}" | ||
| if not params.get("code"): | ||
| return "The login response contains no authorization code." | ||
| return None | ||
|
|
||
|
|
||
| class _CallbackServer(HTTPServer): | ||
| state: str | ||
| callback_params: dict[str, str] | None = None | ||
| login_error: str | None = None | ||
|
|
||
|
|
||
| class _CallbackHandler(BaseHTTPRequestHandler): | ||
| server: _CallbackServer | ||
| # server.timeout only bounds accept(), so an idle connection such as a browser preconnect would block. | ||
| timeout = 2 | ||
|
|
||
| def do_GET(self) -> None: | ||
|
baptistegh marked this conversation as resolved.
Resolved
|
||
| url = urlsplit(self.path) | ||
| if url.path != CALLBACK_PATH or self.server.callback_params is not None: | ||
| self.send_error(404) | ||
| return | ||
| params = {key: values[0] for key, values in parse_qs(url.query).items()} | ||
| login_error = _find_login_error(params, self.server.state) | ||
| self.server.callback_params = params | ||
| self.server.login_error = login_error | ||
| self.send_response(400 if login_error else 200) | ||
| self.send_header("Content-Type", "text/html; charset=utf-8") | ||
| self.end_headers() | ||
| self.wfile.write(_CALLBACK_FAILED_PAGE if login_error else _CALLBACK_PAGE) | ||
|
|
||
| def log_message(self, format, *args) -> None: | ||
| # Request lines contain the authorization code. | ||
| pass | ||
|
|
||
|
|
||
| def get_authorization_code( | ||
| *, authorization_url: str, open_browser: bool, timeout: float | ||
| ) -> AuthorizationCode: | ||
| """ | ||
| Run the browser part of the login and return the authorization code it produced. | ||
|
|
||
| :param authorization_url: URL of the ``authorization_endpoint`` from the CLI login metadata | ||
| :param open_browser: open the login page in the default browser, otherwise only print its URL | ||
| :param timeout: seconds to wait for the user to finish logging in | ||
| """ | ||
| code_verifier, code_challenge = build_pkce_pair() | ||
| state = secrets.token_urlsafe(32) | ||
|
|
||
| with _CallbackServer(("127.0.0.1", 0), _CallbackHandler) as server: | ||
| server.state = state | ||
| redirect_uri = f"http://127.0.0.1:{server.server_address[1]}{CALLBACK_PATH}" | ||
| query = urlencode( | ||
| { | ||
| "redirect_uri": redirect_uri, | ||
| "state": state, | ||
| "code_challenge": code_challenge, | ||
| "code_challenge_method": "S256", | ||
| } | ||
| ) | ||
| login_url = f"{authorization_url}?{query}" | ||
| # `auth token --sso` prints the token on stdout. | ||
| Console(stderr=True, soft_wrap=True).print( | ||
| f"[blue]Log in from your browser:[/blue] {escape(login_url)}" | ||
| ) | ||
| if open_browser: | ||
| webbrowser.open(login_url) | ||
|
|
||
| deadline = time.monotonic() + timeout | ||
|
baptistegh marked this conversation as resolved.
Resolved
|
||
| while server.callback_params is None: | ||
| remaining = deadline - time.monotonic() | ||
| if remaining <= 0: | ||
| raise AirflowCtlBrowserLoginException(f"No login completed within {timeout:g} seconds.") | ||
| server.timeout = remaining | ||
| server.handle_request() | ||
| params = server.callback_params | ||
| login_error = server.login_error | ||
|
|
||
| if login_error: | ||
| raise AirflowCtlBrowserLoginException(login_error) | ||
| return AuthorizationCode(code=params["code"], code_verifier=code_verifier, redirect_uri=redirect_uri) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The browser still says "Login complete" when the code exchange fails so maybe use more neutral words?