Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 40 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,63 @@
Python 3 library providing foundations for Kognic Authentication
on top of the `requests` or `httpx` libraries.

Install with `pip install kognic-auth[requests]` or `pip install kognic-auth[httpx]`
Install with `pip install kognic-auth[requests]` or `pip install kognic-auth[httpx]`

Builds on the standard OAuth 2.0 Client Credentials flow. There are a few ways to provide auth credentials to our api
clients. Kognic Python clients such as in `kognic-io` accept an `auth` parameter that
can be set explicitly or you can omit it and use environment variables.
can be set explicitly or you can omit it and use environment variables.

There are a few ways to set your credentials in `auth`.
1. Set the environment variable `KOGNIC_CREDENTIALS` to point to your Api Credentials file.
There are a few ways to set your credentials in `auth`.
1. Set the environment variable `KOGNIC_CREDENTIALS` to point to your Api Credentials file.
The credentials will contain the Client Id and Client Secret.
2. Set to the credentials file path like `auth="~/.config/kognic/credentials.json"`
2. Set to the credentials file path like `auth="~/.config/kognic/credentials.json"`
3. Set environment variables `KOGNIC_CLIENT_ID` and`KOGNIC_CLIENT_SECRET`
4. Set to credentials tuple `auth=(client_id, client_secret)`
4. Set to credentials tuple `auth=(client_id, client_secret)`

API clients such as the `InputApiClient` accept this `auth` parameter.

Under the hood, they commonly use the AuthSession class which is implements a `requests` session with automatic token
refresh. An `httpx` implementation is also available.
refresh. An `httpx` implementation is also available.
```python
from kognic.auth.requests.auth_session import RequestsAuthSession

sess = RequestsAuthSession()

# make call to some Kognic service with your token. Use default requests
# make call to some Kognic service with your token. Use default requests
sess.get("https://api.app.kognic.com")
```

## CLI

The package provides a command-line interface for generating access tokens, great for MCP integrations.

```bash
kognic-auth get-access-token [--server SERVER] [--credentials FILE]
```

**Options:**
- `--server` - Authentication server URL (default: `https://auth.app.kognic.com`)
- `--credentials` - Path to JSON credentials file. If not provided, credentials are read from environment variables.

The token endpoint is constructed by appending `/v1/auth/oauth/token` to the server URL. For example:
- Default: `https://auth.app.kognic.com/v1/auth/oauth/token`
- Custom: `https://custom.server/v1/auth/oauth/token`

**Exit codes:**
- `0` - Success
- `1` - Error (missing credentials, invalid credentials file, authentication failure, etc.)

**Examples:**
```bash
# Using environment variables (KOGNIC_CREDENTIALS or KOGNIC_CLIENT_ID/KOGNIC_CLIENT_SECRET)
kognic-auth get-access-token

# Using a credentials file
kognic-auth get-access-token --credentials ~/.config/kognic/credentials.json

# Using a custom authentication server
kognic-auth get-access-token --server https://auth.<env>.kognic.com
```

## Changelog
See Github releases from v3.1.0, historic changelog is available in CHANGELOG.md
13 changes: 11 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[build-system]
requires = [
"setuptools>45",
"setuptools>45",
"setuptools_scm[toml]>=6.2"
]
build-backend = "setuptools.build_meta"
Expand Down Expand Up @@ -29,6 +29,9 @@ classifiers = [
[project.urls]
homepage = "https://github.com/annotell/kognic-auth-python"

[project.scripts]
kognic-auth = "kognic.auth.cli:main"

[tool.setuptools_scm]
write_to = "src/kognic/auth/_version.py"

Expand All @@ -43,10 +46,16 @@ full = [
"httpx>=0.20,<1",
"requests>=2.20,<3"
]
[dependency-groups]
dev = [
Comment thread
kognic-willim marked this conversation as resolved.
"httpx>=0.20,<1",
"requests>=2.20,<3",
"pytest",
]

[tool.ruff]
line-length = 120
target-version = "py38"

[tool.ruff.lint]
select = ["E", "F", "B", "W", "I001"]
select = ["E", "F", "B", "W", "I001"]
72 changes: 72 additions & 0 deletions src/kognic/auth/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import argparse
import sys

from kognic.auth import DEFAULT_HOST


def create_parser():
parser = argparse.ArgumentParser(
prog="kognic-auth",
description="Kognic authentication CLI",
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")

# get-access-token subcommand
token_parser = subparsers.add_parser(
"get-access-token",
help="Generate an access token for Kognic API authentication",
)
token_parser.add_argument(
"--server",
default=DEFAULT_HOST,
help=f"Authentication server URL (default: {DEFAULT_HOST})",
)
token_parser.add_argument(
"--credentials",
metavar="FILE",
help="Path to JSON credentials file. If not provided, credentials are read from environment variables.",
)

return parser


def get_access_token(parsed):
try:
from kognic.auth.requests.auth_session import RequestsAuthSession
except ImportError:
print("Error: requests library is required. Install with: pip install kognic-auth[requests]", file=sys.stderr)
return 1

try:
session = RequestsAuthSession(
auth=parsed.credentials,
host=parsed.server,
)
# Access .session to trigger token fetch
_ = session.session
print(session.access_token)
return 0
except FileNotFoundError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error fetching token: {e}", file=sys.stderr)
return 1


def main(args=None):
parser = create_parser()
parsed = parser.parse_args(args)

if parsed.command == "get-access-token":
return get_access_token(parsed)
else:
parser.print_help()
return 0


if __name__ == "__main__":
sys.exit(main())
126 changes: 126 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import unittest
from unittest import mock

from kognic.auth import DEFAULT_HOST
from kognic.auth.cli import create_parser, main


class CliParserTest(unittest.TestCase):
def test_default_server(self):
parser = create_parser()
args = parser.parse_args(["get-access-token"])
self.assertEqual(args.server, DEFAULT_HOST)
self.assertIsNone(args.credentials)

def test_custom_server(self):
parser = create_parser()
args = parser.parse_args(["get-access-token", "--server", "https://custom.auth.server"])
self.assertEqual(args.server, "https://custom.auth.server")

def test_credentials_file(self):
parser = create_parser()
args = parser.parse_args(["get-access-token", "--credentials", "/path/to/creds.json"])
self.assertEqual(args.credentials, "/path/to/creds.json")

def test_all_options(self):
parser = create_parser()
args = parser.parse_args(
["get-access-token", "--server", "https://my.server", "--credentials", "my_creds.json"]
)
self.assertEqual(args.server, "https://my.server")
self.assertEqual(args.credentials, "my_creds.json")

def test_no_command_shows_help(self):
with mock.patch("builtins.print"):
result = main([])
self.assertEqual(result, 0)


class CliMainTest(unittest.TestCase):
@mock.patch("kognic.auth.requests.auth_session.RequestsAuthSession")
def test_main_prints_token(self, mock_session_class):
mock_session = mock.MagicMock()
mock_session.access_token = "test-access-token-123"
mock_session_class.return_value = mock_session

with mock.patch("builtins.print") as mock_print:
result = main(["get-access-token"])

self.assertEqual(result, 0)
mock_print.assert_called_once_with("test-access-token-123")
mock_session_class.assert_called_once_with(auth=None, host=DEFAULT_HOST)

@mock.patch("kognic.auth.requests.auth_session.RequestsAuthSession")
def test_main_with_credentials_file(self, mock_session_class):
mock_session = mock.MagicMock()
mock_session.access_token = "token-from-file"
mock_session_class.return_value = mock_session

with mock.patch("builtins.print") as mock_print:
result = main(["get-access-token", "--credentials", "/path/to/creds.json"])

self.assertEqual(result, 0)
mock_print.assert_called_once_with("token-from-file")
mock_session_class.assert_called_once_with(auth="/path/to/creds.json", host=DEFAULT_HOST)

@mock.patch("kognic.auth.requests.auth_session.RequestsAuthSession")
def test_main_with_custom_server(self, mock_session_class):
mock_session = mock.MagicMock()
mock_session.access_token = "custom-server-token"
mock_session_class.return_value = mock_session

with mock.patch("builtins.print") as mock_print:
result = main(["get-access-token", "--server", "https://custom.server"])

self.assertEqual(result, 0)
mock_print.assert_called_once_with("custom-server-token")
mock_session_class.assert_called_once_with(auth=None, host="https://custom.server")

@mock.patch("kognic.auth.requests.auth_session.RequestsAuthSession")
def test_main_with_all_options(self, mock_session_class):
mock_session = mock.MagicMock()
mock_session.access_token = "full-options-token"
mock_session_class.return_value = mock_session

with mock.patch("builtins.print"):
result = main(["get-access-token", "--server", "https://my.server", "--credentials", "creds.json"])

self.assertEqual(result, 0)
mock_session_class.assert_called_once_with(auth="creds.json", host="https://my.server")

@mock.patch("kognic.auth.requests.auth_session.RequestsAuthSession")
def test_main_file_not_found(self, mock_session_class):
mock_session_class.side_effect = FileNotFoundError("Could not find Api Credentials file at /bad/path.json")

with mock.patch("builtins.print") as mock_print:
result = main(["get-access-token", "--credentials", "/bad/path.json"])

self.assertEqual(result, 1)
mock_print.assert_called_once()
self.assertIn("Error:", mock_print.call_args[0][0])

@mock.patch("kognic.auth.requests.auth_session.RequestsAuthSession")
def test_main_value_error(self, mock_session_class):
mock_session_class.side_effect = ValueError("Bad auth credentials")

with mock.patch("builtins.print") as mock_print:
result = main(["get-access-token"])

self.assertEqual(result, 1)
mock_print.assert_called_once()
self.assertIn("Error:", mock_print.call_args[0][0])

@mock.patch("kognic.auth.requests.auth_session.RequestsAuthSession")
def test_main_generic_exception(self, mock_session_class):
mock_session_class.side_effect = Exception("Network error")

with mock.patch("builtins.print") as mock_print:
result = main(["get-access-token"])

self.assertEqual(result, 1)
mock_print.assert_called_once()
self.assertIn("Error fetching token:", mock_print.call_args[0][0])


if __name__ == "__main__":
unittest.main()
Loading
Loading