Skip to content

Commit

Permalink
Added authentication capability
Browse files Browse the repository at this point in the history
  • Loading branch information
coordt committed Jan 23, 2023
1 parent 872afdc commit 19b5a8d
Show file tree
Hide file tree
Showing 9 changed files with 284 additions and 45 deletions.
103 changes: 103 additions & 0 deletions cookie_composer/authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""OAuth2 authentication to access protected resources."""
from typing import Optional

import json
from pathlib import Path


def get_hosts_file() -> Path:
"""Return the path to the hosts file."""
config_dir = Path("~/.config/cookiecomposer").expanduser().resolve()
config_dir.mkdir(mode=0o755, parents=True, exist_ok=True)
return config_dir / "hosts.json"


def login_to_svc(
service: Optional[str] = None,
protocol: Optional[str] = None,
scopes: Optional[str] = None,
token: Optional[str] = None,
) -> str:
"""
Log in and cache token.
Args:
service: The name of the service to authenticate with
protocol: The protocol to use for git operations
scopes: Additional authentication scopes to request
token: A specific token to use instead of logging in
Returns:
The token for the service
"""
import questionary

hosts_file = get_hosts_file()
hosts = json.loads(hosts_file.read_text()) if hosts_file.exists() else {}

if not service:
title = "What account do you want to log into?"
options = [
"github.com",
]
service = questionary.select(title, options).ask()

if not protocol:
title = "What is your preferred protocol for Git operations?"
options = ["ssh", "https"]
protocol = questionary.select(title, options).ask()

if not token:
token = github_auth_device() if service == "github.com" else ""
hosts[service] = {"git_protocol": protocol, "oauth_token": token}

hosts_file.write_text(json.dumps(hosts))

return token


def get_cached_token(account_name: str) -> Optional[str]:
"""Return the cached token for the account."""
hosts_file = get_hosts_file()
hosts = json.loads(hosts_file.read_text()) if hosts_file.exists() else {}
return hosts.get(account_name, {}).get("oauth_token")


def add_auth_to_url(url: str) -> str:
"""
Add authentication information to a URL.
Args:
url: The URL to add authentication information to.
Returns:
The URL with authentication information added, or the original URL if no token is cached
"""
from urllib.parse import urlparse, urlunparse

parsed = urlparse(url)
if parsed.netloc == "github.com":
token = get_cached_token("github.com")
if token:
parsed = parsed._replace(netloc=f"cookiecomposer:{token}@{parsed.netloc}")
return urlunparse(parsed)


def github_auth_device(n_polls=9999):
"""
Authenticate with GitHub, polling up to ``n_polls`` times to wait for completion.
"""
from ghapi.auth import GhDeviceAuth

auth = GhDeviceAuth(client_id="de4e3ca9028661a80b50")
print(f"First copy your one-time code: \x1b[33m{auth.user_code}\x1b[m")
print(f"Then visit {auth.verification_uri} in your browser, and paste the code when prompted.")
input("Press Enter to open github.com in your browser...")
auth.open_browser()

print("Waiting for authorization...", end="")
token = auth.wait(lambda: print(".", end=""), n_polls=n_polls)
if not token:
return print("Authentication not complete!")
print("Authenticated to GitHub")
return token
4 changes: 4 additions & 0 deletions cookie_composer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import rich_click as click

from cookie_composer.commands.add import add_cmd
from cookie_composer.commands.auth import auth_cli
from cookie_composer.commands.create import create_cmd
from cookie_composer.commands.link import link_cmd
from cookie_composer.commands.update import update_cmd
Expand All @@ -23,6 +24,9 @@ def cli():
pass


cli.add_command(auth_cli, name="auth")


@cli.command()
@click_log.simple_verbosity_option(logger)
@click.option(
Expand Down
78 changes: 78 additions & 0 deletions cookie_composer/commands/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Authentication subcommands."""
import sys

import rich_click as click

from cookie_composer.authentication import get_cached_token, login_to_svc


@click.group()
def auth_cli():
"""Authenticate cookie-composer to a service."""
pass


@auth_cli.command()
@click.option(
"-p",
"--git-protocol",
type=click.Choice(["https", "ssh"]),
default="https",
help="The protocol to use for git operations",
)
@click.option(
"-h",
"--service",
type=str,
help="The host name of the service to authenticate with",
default="github.com",
)
@click.option("-s", "--scopes", type=str, help="Additional authentication scopes to request")
@click.option(
"--with-token", type=click.File("r"), is_flag=False, flag_value=sys.stdin, help="Read token from standard input"
)
def login(git_protocol: str, service: str, scopes: str, with_token: click.File):
"""Authenticate to a service."""
w_token = with_token.read() if with_token else None
if not w_token and get_cached_token(service):
click.echo("Already logged in.")
else:
login_to_svc(service, git_protocol, scopes, w_token)


# TODO: Implement logout command
# @auth_cli.command()
# def logout():
# """Log out of a host."""
# pass


# TODO: Implement refresh command
# @auth_cli.command()
# def refresh():
# """Refresh stored authentication credentials."""
# pass


# TODO: Implement status command
# @auth_cli.command()
# def status():
# """View authentication status."""
# pass


@auth_cli.command()
@click.option(
"-h",
"--service",
type=str,
help="The host name of the service to authenticate with",
default="github.com",
)
def token(service: str):
"""Print the auth token cookie-composer is configured to use."""
oauth_token = get_cached_token(service)
if oauth_token:
click.echo(oauth_token)
else:
raise click.UsageError(f"No cookie-composer auth token configured for {service}.")
4 changes: 3 additions & 1 deletion cookie_composer/layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,12 @@ def render_layer(
Returns:
The rendered layer information
"""
from cookie_composer.authentication import add_auth_to_url

full_context = full_context or Context()
user_config = get_user_config(config_file=None, default_config=False)
repo_dir, cleanup = determine_repo_dir(
template=layer_config.template,
template=add_auth_to_url(layer_config.template),
abbreviations=user_config["abbreviations"],
clone_to_dir=user_config["cookiecutters_dir"],
checkout=layer_config.commit or layer_config.checkout,
Expand Down
56 changes: 37 additions & 19 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#
# This file is autogenerated by pip-compile with python 3.10
# To update, run:
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --output-file=dev.txt dev.in
#
Expand All @@ -10,7 +10,7 @@ aiosignal==1.2.0
# via
# -r test.txt
# aiohttp
alabaster==0.7.12
alabaster==0.7.13
# via
# -r docs.txt
# sphinx
Expand All @@ -29,7 +29,7 @@ attrs==22.1.0
# -r test.txt
# aiohttp
# pytest
babel==2.10.3
babel==2.11.0
# via
# -r docs.txt
# sphinx
Expand Down Expand Up @@ -101,6 +101,10 @@ docutils==0.19
# myst-parser
# sphinx
# sphinx-click
fastcore==1.5.27
# via
# -r test.txt
# ghapi
filelock==3.8.0
# via
# -r test.txt
Expand All @@ -114,10 +118,12 @@ frozenlist==1.3.1
# aiosignal
fsspec==2022.8.2
# via -r test.txt
furo==2022.9.29
furo==2022.12.7
# via -r docs.txt
generate-changelog==0.9.0
# via -r dev.in
ghapi==1.0.3
# via -r test.txt
ghp-import==2.1.0
# via -r docs.txt
git-fame==1.15.2
Expand Down Expand Up @@ -179,7 +185,7 @@ mccabe==0.7.0
# via
# -r test.txt
# flake8
mdit-py-plugins==0.3.1
mdit-py-plugins==0.3.3
# via
# -r docs.txt
# myst-parser
Expand All @@ -206,11 +212,13 @@ nodeenv==1.7.0
# pre-commit
orjson==3.8.3
# via -r test.txt
packaging==21.3
packaging==23.0
# via
# -r docs.txt
# -r test.txt
# build
# fastcore
# ghapi
# pytest
# sphinx
pathspec==0.10.1
Expand All @@ -232,6 +240,10 @@ pluggy==1.0.0
# pytest
pre-commit==2.20.0
# via -r test.txt
prompt-toolkit==3.0.36
# via
# -r test.txt
# questionary
py==1.11.0
# via
# -r test.txt
Expand All @@ -253,11 +265,6 @@ pygments==2.13.0
# furo
# rich
# sphinx
pyparsing==3.0.9
# via
# -r docs.txt
# -r test.txt
# packaging
pytest==7.1.3
# via
# -r test.txt
Expand All @@ -280,7 +287,7 @@ python-slugify==6.1.2
# via
# -r test.txt
# cookiecutter
pytz==2022.4
pytz==2022.7.1
# via
# -r docs.txt
# babel
Expand All @@ -291,6 +298,8 @@ pyyaml==6.0
# cookiecutter
# myst-parser
# pre-commit
questionary==1.10.0
# via -r test.txt
requests==2.28.1
# via
# -r docs.txt
Expand All @@ -308,7 +317,9 @@ ruamel-yaml==0.17.21
# -r test.txt
# generate-changelog
ruamel-yaml-clib==0.2.6
# via -r test.txt
# via
# -r test.txt
# ruamel-yaml
six==1.16.0
# via
# -r docs.txt
Expand All @@ -326,7 +337,7 @@ soupsieve==2.3.2.post1
# via
# -r docs.txt
# beautifulsoup4
sphinx==5.2.3
sphinx==5.3.0
# via
# -r docs.txt
# furo
Expand All @@ -335,17 +346,17 @@ sphinx==5.2.3
# sphinx-basic-ng
# sphinx-click
# sphinx-copybutton
sphinx-autodoc-typehints==1.19.4
sphinx-autodoc-typehints==1.21.6
# via -r docs.txt
sphinx-basic-ng==1.0.0b1
# via
# -r docs.txt
# furo
sphinx-click==4.3.0
sphinx-click==4.4.0
# via -r docs.txt
sphinx-copybutton==0.5.0
sphinx-copybutton==0.5.1
# via -r docs.txt
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-applehelp==1.0.3
# via
# -r docs.txt
# sphinx
Expand Down Expand Up @@ -382,6 +393,9 @@ toml==0.10.2
tomli==2.0.1
# via
# -r test.txt
# black
# build
# coverage
# pytest
tqdm==4.64.1
# via git-fame
Expand All @@ -406,6 +420,10 @@ virtualenv==20.16.2
# via
# -r test.txt
# pre-commit
wcwidth==0.2.6
# via
# -r test.txt
# prompt-toolkit
wheel==0.38.1
# via pip-tools
yarl==1.8.1
Expand Down
Loading

0 comments on commit 19b5a8d

Please sign in to comment.