apppy-auth 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apppy_auth-0.1.0/.gitignore +28 -0
- apppy_auth-0.1.0/PKG-INFO +23 -0
- apppy_auth-0.1.0/README.md +0 -0
- apppy_auth-0.1.0/auth.mk +23 -0
- apppy_auth-0.1.0/pyproject.toml +37 -0
- apppy_auth-0.1.0/src/apppy/auth/__init__.py +19 -0
- apppy_auth-0.1.0/src/apppy/auth/errors/__init__.py +0 -0
- apppy_auth-0.1.0/src/apppy/auth/errors/jwks.py +10 -0
- apppy_auth-0.1.0/src/apppy/auth/errors/oauth.py +24 -0
- apppy_auth-0.1.0/src/apppy/auth/errors/service.py +34 -0
- apppy_auth-0.1.0/src/apppy/auth/errors/user.py +69 -0
- apppy_auth-0.1.0/src/apppy/auth/jwks.py +255 -0
- apppy_auth-0.1.0/src/apppy/auth/jwks_unit_test.py +21 -0
- apppy_auth-0.1.0/src/apppy/auth/jwt.py +200 -0
- apppy_auth-0.1.0/src/apppy/auth/jwt_unit_test.py +160 -0
- apppy_auth-0.1.0/src/apppy/auth/oauth.py +31 -0
- apppy_auth-0.1.0/src/apppy/auth/permissions.py +281 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
__generated__/
|
|
2
|
+
dist/
|
|
3
|
+
*.egg-info
|
|
4
|
+
.env
|
|
5
|
+
.env.*
|
|
6
|
+
*.env
|
|
7
|
+
!.env.ci
|
|
8
|
+
.file_store/
|
|
9
|
+
*.pid
|
|
10
|
+
.python-version
|
|
11
|
+
*.secrets
|
|
12
|
+
.secrets
|
|
13
|
+
*.tar.gz
|
|
14
|
+
*.test_output/
|
|
15
|
+
.test_output/
|
|
16
|
+
uv.lock
|
|
17
|
+
*.whl
|
|
18
|
+
|
|
19
|
+
# System files
|
|
20
|
+
__pycache__
|
|
21
|
+
.DS_Store
|
|
22
|
+
|
|
23
|
+
# Editor files
|
|
24
|
+
*.sublime-project
|
|
25
|
+
*.sublime-workspace
|
|
26
|
+
.vscode/*
|
|
27
|
+
!.vscode/settings.json
|
|
28
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apppy-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Authentication definitions for server development
|
|
5
|
+
Project-URL: Homepage, https://github.com/spals/apppy
|
|
6
|
+
Author: Tim Kral
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: apppy-env>=0.1.0
|
|
12
|
+
Requires-Dist: apppy-fastql>=0.1.0
|
|
13
|
+
Requires-Dist: apppy-fs>=0.1.0
|
|
14
|
+
Requires-Dist: apppy-logger>=0.1.0
|
|
15
|
+
Requires-Dist: argon2-cffi==25.1.0
|
|
16
|
+
Requires-Dist: authlib==1.6.0
|
|
17
|
+
Requires-Dist: fastapi-another-jwt-auth==0.1.11
|
|
18
|
+
Requires-Dist: fastapi-lifespan-manager==0.1.4
|
|
19
|
+
Requires-Dist: fastapi==0.115.14
|
|
20
|
+
Requires-Dist: itsdangerous==2.2.0
|
|
21
|
+
Requires-Dist: jwcrypto==1.5.6
|
|
22
|
+
Requires-Dist: pyjwt==2.10.1
|
|
23
|
+
Requires-Dist: python-dateutil==2.9.0
|
|
File without changes
|
apppy_auth-0.1.0/auth.mk
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
ifndef APPPY_AUTH_MK_INCLUDED
|
|
2
|
+
APPPY_AUTH_MK_INCLUDED := 1
|
|
3
|
+
AUTH_PKG_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
|
4
|
+
|
|
5
|
+
.PHONY: auth auth-dev auth/build auth/clean auth/install auth/install-dev
|
|
6
|
+
|
|
7
|
+
auth: auth/clean auth/install
|
|
8
|
+
|
|
9
|
+
auth-dev: auth/clean auth/install-dev
|
|
10
|
+
|
|
11
|
+
auth/build:
|
|
12
|
+
cd $(AUTH_PKG_DIR) && uvx --from build pyproject-build
|
|
13
|
+
|
|
14
|
+
auth/clean:
|
|
15
|
+
cd $(AUTH_PKG_DIR) && rm -rf dist/ *.egg-info .venv
|
|
16
|
+
|
|
17
|
+
auth/install: auth/build
|
|
18
|
+
cd $(AUTH_PKG_DIR) && uv pip install dist/*.whl
|
|
19
|
+
|
|
20
|
+
auth/install-dev:
|
|
21
|
+
cd $(AUTH_PKG_DIR) && uv pip install -e .
|
|
22
|
+
|
|
23
|
+
endif # APPPY_AUTH_MK_INCLUDED
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "apppy-auth"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Authentication definitions for server development"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{ name = "Tim Kral" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"apppy-env>=0.1.0",
|
|
19
|
+
"apppy-fastql>=0.1.0",
|
|
20
|
+
"apppy-fs>=0.1.0",
|
|
21
|
+
"apppy-logger>=0.1.0",
|
|
22
|
+
"argon2-cffi==25.1.0",
|
|
23
|
+
"authlib==1.6.0",
|
|
24
|
+
"fastapi==0.115.14",
|
|
25
|
+
"fastapi-lifespan-manager==0.1.4",
|
|
26
|
+
"fastapi-another-jwt-auth==0.1.11",
|
|
27
|
+
"itsdangerous==2.2.0",
|
|
28
|
+
"jwcrypto==1.5.6",
|
|
29
|
+
"PyJWT==2.10.1",
|
|
30
|
+
"python-dateutil==2.9.0"
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/spals/apppy"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/apppy"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from fastapi import Request
|
|
2
|
+
from fastapi_another_jwt_auth import AuthJWT as JWT
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def convert_token_to_jwt(token: str) -> JWT:
|
|
6
|
+
# The JWT infrastructure only deals with http requests
|
|
7
|
+
# and responses so we'll emulate an http request in
|
|
8
|
+
# order to run the JWT parsing
|
|
9
|
+
scope = {
|
|
10
|
+
"type": "http",
|
|
11
|
+
"headers": [(b"authorization", f"Bearer {token}".encode("latin-1"))],
|
|
12
|
+
}
|
|
13
|
+
req = Request(scope=scope)
|
|
14
|
+
return extract_jwt_from_request(req)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extract_jwt_from_request(request: Request) -> JWT:
|
|
18
|
+
jwt = JWT(req=request, res=None) # type: ignore[invalid-argument-type]
|
|
19
|
+
return jwt
|
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from apppy.fastql.annotation import fastql_type_error
|
|
2
|
+
from apppy.fastql.errors import GraphQLServerError
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@fastql_type_error
|
|
6
|
+
class IllegalJwksPemFileError(GraphQLServerError):
|
|
7
|
+
"""Error raised when a JWKS PEM file is not well-formed"""
|
|
8
|
+
|
|
9
|
+
def __init__(self, code: str) -> None:
|
|
10
|
+
super().__init__(code)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from apppy.fastql.annotation import fastql_type_error
|
|
2
|
+
from apppy.fastql.errors import GraphQLClientError
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@fastql_type_error
|
|
6
|
+
class IllegalNativeOAuthProviderError(GraphQLClientError):
|
|
7
|
+
"""Error raised when a non-native OAuth provider is treated as a native one"""
|
|
8
|
+
|
|
9
|
+
provider: str
|
|
10
|
+
|
|
11
|
+
def __init__(self, provider: str) -> None:
|
|
12
|
+
super().__init__("illegal_native_oauth_provider")
|
|
13
|
+
self.provider = provider
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@fastql_type_error
|
|
17
|
+
class UnknownOAuthProviderError(GraphQLClientError):
|
|
18
|
+
"""Error raised when a client has provided an unknown OAuth client name"""
|
|
19
|
+
|
|
20
|
+
provider: str
|
|
21
|
+
|
|
22
|
+
def __init__(self, provider: str) -> None:
|
|
23
|
+
super().__init__("unknown_oauth_provider")
|
|
24
|
+
self.provider = provider
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from apppy.fastql.annotation import fastql_type_error
|
|
2
|
+
from apppy.fastql.errors import GraphQLClientError, GraphQLServerError
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@fastql_type_error
|
|
6
|
+
class ServiceAuthenticaionDisabledError(GraphQLServerError):
|
|
7
|
+
"""Error raised when a service trying to authenticate but service authentication is disabled"""
|
|
8
|
+
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
super().__init__("service_authenticaion_disabled")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@fastql_type_error
|
|
14
|
+
class ServiceKeyAlgorithmMissingError(GraphQLClientError):
|
|
15
|
+
"""Error raised when a service trying to authenticate does not include an algorithm header"""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
super().__init__("service_key_algorithm_missing")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@fastql_type_error
|
|
22
|
+
class ServiceKeyMissingError(GraphQLServerError):
|
|
23
|
+
"""Error raised when a service trying to authenticate does not have a registered public key"""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
super().__init__("service_key_missing")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@fastql_type_error
|
|
30
|
+
class ServiceUnknownError(GraphQLClientError):
|
|
31
|
+
"""Error raised when a service trying to authenticate cannot be found"""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
super().__init__("service_unknown")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from apppy.fastql.annotation import fastql_type_error
|
|
2
|
+
from apppy.fastql.errors import GraphQLClientError, GraphQLServerError
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@fastql_type_error
|
|
6
|
+
class UserSessionRefreshMissingSessionError(GraphQLClientError):
|
|
7
|
+
"""Error raised when a user session refresh is attempted against a missing session"""
|
|
8
|
+
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
super().__init__("user_session_refresh_missing_session")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@fastql_type_error
|
|
14
|
+
class UserSignInInvalidCredentialsError(GraphQLClientError):
|
|
15
|
+
"""Error raised when a user has provided invalid credentials for sign in"""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
super().__init__("user_sign_in_invalid_credentials")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@fastql_type_error
|
|
22
|
+
class UserSignInServerError(GraphQLServerError):
|
|
23
|
+
"""Error raised when there's a user sign in error on the server side"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, code: str) -> None:
|
|
26
|
+
super().__init__(code)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@fastql_type_error
|
|
30
|
+
class UserSignOutInvalidScopeError(GraphQLClientError):
|
|
31
|
+
"""Error raised when a user has provided an invalid scope for sign out"""
|
|
32
|
+
|
|
33
|
+
scope: str
|
|
34
|
+
|
|
35
|
+
def __init__(self, scope: str) -> None:
|
|
36
|
+
super().__init__("user_sign_out_invalid_scope")
|
|
37
|
+
self.scope = scope
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@fastql_type_error
|
|
41
|
+
class UserSignOutServerError(GraphQLServerError):
|
|
42
|
+
"""Error raised when there's a user sign out error on the server side"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, code: str) -> None:
|
|
45
|
+
super().__init__(code)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@fastql_type_error
|
|
49
|
+
class UserSignUpInvalidCredentialsError(GraphQLClientError):
|
|
50
|
+
"""Error raised when a user has provided invalid credentials for sign up"""
|
|
51
|
+
|
|
52
|
+
def __init__(self) -> None:
|
|
53
|
+
super().__init__("user_sign_up_invalid_credentials")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@fastql_type_error
|
|
57
|
+
class UserSignUpTooManyRetriesError(GraphQLServerError):
|
|
58
|
+
"""Error raised when there have been too many user sign up attempts"""
|
|
59
|
+
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
super().__init__("user_sign_up_too_many_retries")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@fastql_type_error
|
|
65
|
+
class UserSignUpServerError(GraphQLServerError):
|
|
66
|
+
"""Error raised when there's a user sign up error on the server side"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, code: str) -> None:
|
|
69
|
+
super().__init__(code)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from fastapi_lifespan_manager import LifespanManager
|
|
6
|
+
from jwcrypto.jwk import JWK
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from apppy.auth.errors.jwks import IllegalJwksPemFileError
|
|
10
|
+
from apppy.auth.errors.service import (
|
|
11
|
+
ServiceAuthenticaionDisabledError,
|
|
12
|
+
ServiceKeyMissingError,
|
|
13
|
+
ServiceUnknownError,
|
|
14
|
+
)
|
|
15
|
+
from apppy.env import EnvSettings
|
|
16
|
+
from apppy.fs import FileSystem, FileUrl
|
|
17
|
+
from apppy.logger import WithLogger
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class JwkPemFile:
|
|
22
|
+
file_url: FileUrl
|
|
23
|
+
|
|
24
|
+
is_public: bool
|
|
25
|
+
service_name: str
|
|
26
|
+
version: int
|
|
27
|
+
generated_at: datetime.datetime
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def file_ext(self) -> str:
|
|
31
|
+
return "pub.pem" if self.is_public else "key.pem"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def kid(self) -> str:
|
|
35
|
+
return f"{self.service_name}.v{self.version}"
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def file_name_private(service_name: str, version: int, generated_at: datetime.datetime) -> str:
|
|
39
|
+
generated_ts = generated_at.strftime("%Y%m%d%H%M%S")
|
|
40
|
+
return f"{service_name}.v{version}.{generated_ts}.key.pem"
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def file_name_public(service_name: str, version: int, generated_at: datetime.datetime) -> str:
|
|
44
|
+
generated_ts = generated_at.strftime("%Y%m%d%H%M%S")
|
|
45
|
+
return f"{service_name}.v{version}.{generated_ts}.pub.pem"
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def from_file_url(file_url: FileUrl) -> "JwkPemFile":
|
|
49
|
+
if file_url.file_name is None:
|
|
50
|
+
raise IllegalJwksPemFileError("unavailable_pem_file_name")
|
|
51
|
+
|
|
52
|
+
service_name, version, generated_at = JwkPemFile._parse_file_name(file_url.file_name)
|
|
53
|
+
return JwkPemFile(
|
|
54
|
+
file_url=file_url,
|
|
55
|
+
is_public=file_url.file_name.endswith("pub.pem"),
|
|
56
|
+
service_name=service_name,
|
|
57
|
+
version=version,
|
|
58
|
+
generated_at=generated_at,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def _parse_file_name(file_name: str) -> tuple[str, int, datetime.datetime]:
|
|
63
|
+
if not file_name.endswith(".pub.pem") and not file_name.endswith(".key.pem"):
|
|
64
|
+
raise IllegalJwksPemFileError("unknown_pem_file_extension")
|
|
65
|
+
|
|
66
|
+
pattern = (
|
|
67
|
+
r"^(?P<service>[a-zA-Z0-9_-]+)\.v(?P<version>\d+)\.(?P<ts>\d{14})\.(pub|key)\.pem$"
|
|
68
|
+
)
|
|
69
|
+
m = re.match(pattern, file_name)
|
|
70
|
+
if not m:
|
|
71
|
+
raise ValueError(f"Invalid filename format: {file_name}")
|
|
72
|
+
|
|
73
|
+
service_name = m.group("service")
|
|
74
|
+
version = int(m.group("version"))
|
|
75
|
+
ts_str = m.group("ts")
|
|
76
|
+
generated_at = datetime.datetime.strptime(ts_str, "%Y%m%d%H%M%S")
|
|
77
|
+
|
|
78
|
+
return (service_name, version, generated_at)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class JwkInfo:
|
|
83
|
+
key: dict
|
|
84
|
+
jwk: JWK
|
|
85
|
+
pem: JwkPemFile
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class JwksAuthStorageSettings(EnvSettings):
|
|
89
|
+
# NOTE: Some of these configuration values are general to all authentication
|
|
90
|
+
# so those use the APP_AUTH prefix. Configuration values specific to jwks use
|
|
91
|
+
# the APP_JWKS_AUTH prefix.
|
|
92
|
+
|
|
93
|
+
# Control whether JWKS authenication is available.
|
|
94
|
+
# If not available (the default), JwksAuth will not attempt to read
|
|
95
|
+
# any files from the FileSystem.
|
|
96
|
+
# Note that this is useful for integration tests. Some integration tests
|
|
97
|
+
# run with an encrypted FileSystem and some without. So they cannot share
|
|
98
|
+
# pem files.
|
|
99
|
+
jwks_auth_enabled: bool = Field(alias="APP_AUTH_SERVICES_ENABLED", default=False)
|
|
100
|
+
jwks_auth_fs_partition: str = Field(alias="APP_AUTH_FS_PARTITION", default="auth")
|
|
101
|
+
|
|
102
|
+
jwks_auth_root_dir: str = Field(alias="APP_JWKS_AUTH_ROOT_DIR", default=".jwks")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class JwksAuthStorage(WithLogger):
|
|
106
|
+
def __init__(
|
|
107
|
+
self, settings: JwksAuthStorageSettings, lifespan: LifespanManager, fs: FileSystem
|
|
108
|
+
):
|
|
109
|
+
self._settings = settings
|
|
110
|
+
self._fs = fs
|
|
111
|
+
|
|
112
|
+
self._jwks_root_file_url: FileUrl = self._fs.new_file_url_internal(
|
|
113
|
+
protocol=self._fs.settings.default_protocol,
|
|
114
|
+
partition=settings.jwks_auth_fs_partition,
|
|
115
|
+
directory=settings.jwks_auth_root_dir,
|
|
116
|
+
file_name=None,
|
|
117
|
+
)
|
|
118
|
+
self._jwks_full_cache: dict[str, list[JwkInfo]] = {}
|
|
119
|
+
self._jwks_version_cache: dict[str, int] = {}
|
|
120
|
+
|
|
121
|
+
self._keys: list[dict] = []
|
|
122
|
+
self._keys_dict = {"keys": self._keys}
|
|
123
|
+
if self._settings.jwks_auth_enabled is True:
|
|
124
|
+
lifespan.add(self.rebuild_caches)
|
|
125
|
+
|
|
126
|
+
def _clear_cache(self):
|
|
127
|
+
self._jwks_full_cache.clear()
|
|
128
|
+
self._jwks_version_cache.clear()
|
|
129
|
+
self._keys.clear()
|
|
130
|
+
|
|
131
|
+
def add_jwk(self, pem_file_url: FileUrl) -> JwkInfo:
|
|
132
|
+
if not self._fs.exists(self._jwks_root_file_url):
|
|
133
|
+
self._fs.makedir(self._jwks_root_file_url, create_parents=True)
|
|
134
|
+
|
|
135
|
+
pem_file: JwkPemFile = JwkPemFile.from_file_url(pem_file_url)
|
|
136
|
+
pem_file_bytes: bytes = self._fs.read_bytes(pem_file_url)
|
|
137
|
+
jwk_: JWK = JWK.from_pem(pem_file_bytes)
|
|
138
|
+
key = jwk_.export_public(as_dict=True)
|
|
139
|
+
key["use"] = "sig"
|
|
140
|
+
key["alg"] = "EdDSA"
|
|
141
|
+
key["kid"] = pem_file.kid
|
|
142
|
+
|
|
143
|
+
if pem_file.service_name not in self._jwks_full_cache:
|
|
144
|
+
self._jwks_full_cache[pem_file.service_name] = []
|
|
145
|
+
|
|
146
|
+
jwk_info = JwkInfo(key=key, jwk=jwk_, pem=pem_file)
|
|
147
|
+
self._jwks_full_cache[pem_file.service_name].append(jwk_info)
|
|
148
|
+
|
|
149
|
+
if (
|
|
150
|
+
pem_file.service_name not in self._jwks_version_cache
|
|
151
|
+
or self._jwks_version_cache[pem_file.service_name] < pem_file.version
|
|
152
|
+
):
|
|
153
|
+
self._jwks_version_cache[pem_file.service_name] = pem_file.version
|
|
154
|
+
|
|
155
|
+
self._keys.append(key)
|
|
156
|
+
return jwk_info
|
|
157
|
+
|
|
158
|
+
def all_services(self) -> dict[str, list[JwkPemFile]]:
|
|
159
|
+
if self._settings.jwks_auth_enabled is False:
|
|
160
|
+
raise ServiceAuthenticaionDisabledError()
|
|
161
|
+
|
|
162
|
+
return {s: [j.pem for j in jwk_infos] for s, jwk_infos in self._jwks_full_cache.items()}
|
|
163
|
+
|
|
164
|
+
def clear_all_jwks(self) -> None:
|
|
165
|
+
if self._fs.exists(self._jwks_root_file_url):
|
|
166
|
+
self._fs.rm(self._jwks_root_file_url, recursive=True)
|
|
167
|
+
|
|
168
|
+
self._clear_cache()
|
|
169
|
+
|
|
170
|
+
def current_service_version(self, service_name: str) -> int | None:
|
|
171
|
+
if self._settings.jwks_auth_enabled is False:
|
|
172
|
+
raise ServiceAuthenticaionDisabledError()
|
|
173
|
+
|
|
174
|
+
return self._jwks_version_cache.get(service_name)
|
|
175
|
+
|
|
176
|
+
def get_jwk(self, kid: str) -> JwkInfo:
|
|
177
|
+
if self._settings.jwks_auth_enabled is False:
|
|
178
|
+
raise ServiceAuthenticaionDisabledError()
|
|
179
|
+
|
|
180
|
+
service_name = kid.split(".")[0]
|
|
181
|
+
if service_name not in self._jwks_full_cache:
|
|
182
|
+
raise ServiceUnknownError()
|
|
183
|
+
|
|
184
|
+
jwk_info = next(
|
|
185
|
+
filter(
|
|
186
|
+
lambda e: e.pem.kid == kid,
|
|
187
|
+
self._jwks_full_cache[service_name],
|
|
188
|
+
),
|
|
189
|
+
None,
|
|
190
|
+
)
|
|
191
|
+
if jwk_info is None:
|
|
192
|
+
raise ServiceKeyMissingError()
|
|
193
|
+
|
|
194
|
+
return jwk_info
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def is_enabled(self) -> bool:
|
|
198
|
+
return self._settings.jwks_auth_enabled
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def keys_dict(self) -> dict:
|
|
202
|
+
if self._settings.jwks_auth_enabled is False:
|
|
203
|
+
raise ServiceAuthenticaionDisabledError()
|
|
204
|
+
|
|
205
|
+
return self._keys_dict
|
|
206
|
+
|
|
207
|
+
async def rebuild_caches(self):
|
|
208
|
+
if self._settings.jwks_auth_enabled is False:
|
|
209
|
+
raise ServiceAuthenticaionDisabledError()
|
|
210
|
+
|
|
211
|
+
if not self._fs.exists(self._jwks_root_file_url):
|
|
212
|
+
self._fs.makedir(self._jwks_root_file_url, create_parents=True)
|
|
213
|
+
|
|
214
|
+
self._clear_cache()
|
|
215
|
+
|
|
216
|
+
for service_dir in self._fs.ls(self._jwks_root_file_url):
|
|
217
|
+
service_dir_url = self._fs.parse_file_url(service_dir["url"])
|
|
218
|
+
if self._fs.isdir(service_dir_url):
|
|
219
|
+
self._jwks_full_cache[service_dir_url.directory] = []
|
|
220
|
+
for pem_file in self._fs.ls(service_dir_url):
|
|
221
|
+
pem_file_url = self._fs.parse_file_url(pem_file["url"])
|
|
222
|
+
if self._fs.isfile(pem_file_url):
|
|
223
|
+
self.add_jwk(pem_file_url)
|
|
224
|
+
|
|
225
|
+
yield {
|
|
226
|
+
"jwks_full_cache": self._jwks_full_cache,
|
|
227
|
+
"jwks_version_cache": self._jwks_version_cache,
|
|
228
|
+
"keys": self._keys,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
# IMPORTANT NOTE: Each server instance keeps an in-memory cache of
|
|
232
|
+
# all jwks records. Thus if you are running multiple instances and
|
|
233
|
+
# you write a new public key (e.g. due to regular rotation maintenance)
|
|
234
|
+
# you will need to ensure that all server instances are rebooted.
|
|
235
|
+
async def write_public_key(self, service_name: str, key_bytes: bytes) -> JwkInfo:
|
|
236
|
+
if self._settings.jwks_auth_enabled is False:
|
|
237
|
+
raise ServiceAuthenticaionDisabledError()
|
|
238
|
+
|
|
239
|
+
version = (
|
|
240
|
+
self._jwks_version_cache[service_name] + 1
|
|
241
|
+
if service_name in self._jwks_version_cache
|
|
242
|
+
else 0
|
|
243
|
+
)
|
|
244
|
+
generated_at = datetime.datetime.now(datetime.UTC)
|
|
245
|
+
|
|
246
|
+
pem_file_url: FileUrl = self._jwks_root_file_url.join(
|
|
247
|
+
directory=service_name,
|
|
248
|
+
file_name=JwkPemFile.file_name_public(service_name, version, generated_at),
|
|
249
|
+
)
|
|
250
|
+
service_dir_url = pem_file_url.parent()
|
|
251
|
+
if not self._fs.exists(service_dir_url):
|
|
252
|
+
self._fs.makedir(url=pem_file_url.parent(), create_parents=True)
|
|
253
|
+
|
|
254
|
+
pem_file_url, _ = await self._fs.write_bytes(pem_file_url, key_bytes)
|
|
255
|
+
return self.add_jwk(pem_file_url)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
from apppy.auth.jwks import JwkPemFile
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_jwk_parse_file_name_private():
|
|
7
|
+
service_name, version, generated_at = JwkPemFile._parse_file_name(
|
|
8
|
+
"test_jwk_pem_file_public.v0.20250905215639.key.pem"
|
|
9
|
+
)
|
|
10
|
+
assert service_name == "test_jwk_pem_file_public"
|
|
11
|
+
assert version == 0
|
|
12
|
+
assert generated_at == datetime.datetime(2025, 9, 5, 21, 56, 39, 0)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_jwk_parse_file_name_public():
|
|
16
|
+
service_name, version, generated_at = JwkPemFile._parse_file_name(
|
|
17
|
+
"test_jwk_pem_file_public.v0.20250905215639.pub.pem"
|
|
18
|
+
)
|
|
19
|
+
assert service_name == "test_jwk_pem_file_public"
|
|
20
|
+
assert version == 0
|
|
21
|
+
assert generated_at == datetime.datetime(2025, 9, 5, 21, 56, 39, 0)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import contextvars
|
|
2
|
+
import datetime
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from fastapi import HTTPException, Request
|
|
8
|
+
from fastapi_another_jwt_auth import AuthJWT as JWT
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
|
|
11
|
+
from apppy.auth import convert_token_to_jwt, extract_jwt_from_request
|
|
12
|
+
from apppy.env import EnvSettings
|
|
13
|
+
from apppy.fastql.errors import GraphQLClientError, GraphQLError, GraphQLServerError
|
|
14
|
+
from apppy.fastql.permissions import GraphQLPermission
|
|
15
|
+
from apppy.logger import WithLogger
|
|
16
|
+
|
|
17
|
+
JwtAuthSubjectType = Literal["user", "service"]
|
|
18
|
+
|
|
19
|
+
# Define a context variable per key
|
|
20
|
+
_current_auth_context: contextvars.ContextVar["JwtAuthContext"] = contextvars.ContextVar(
|
|
21
|
+
"_current_auth_context"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# This breaks the typically logger pattern
|
|
25
|
+
# but logging is needed in static methods
|
|
26
|
+
_logger = logging.getLogger("apppy.auth.jwt.JwtAuthContext")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JwtAuthSettings(EnvSettings):
|
|
30
|
+
# The names here must match the names found in
|
|
31
|
+
# fastapi_another_jwt_auth.AuthConfig.load_config
|
|
32
|
+
authjwt_decode_audience: str = Field(
|
|
33
|
+
alias="APP_JWT_AUTH_DECODE_AUDIENCE", default="authenticated"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
authjwt_encode_issuer: str = Field(alias="APP_JWT_AUTH_ENCODE_ISSUER")
|
|
37
|
+
authjwt_secret_key: str = Field(alias="APP_JWT_AUTH_SECRET_KEY", exclude=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class JwtAuthContext(WithLogger):
|
|
42
|
+
# In some cases we'll encounter an error
|
|
43
|
+
# while preprocessing a JwtAuthContext. The classic
|
|
44
|
+
# example of this is attempting a service authentication
|
|
45
|
+
# when service authentication is disabled. For those cases,
|
|
46
|
+
# we'll store the preprocessing error code here so that we
|
|
47
|
+
# can return the appropriate error type to the client.
|
|
48
|
+
preprocessing_error: GraphQLError | None = None
|
|
49
|
+
|
|
50
|
+
has_token: bool = False
|
|
51
|
+
is_token_well_formed: bool = False
|
|
52
|
+
expires_at: int = 0
|
|
53
|
+
issued_at: int = 0
|
|
54
|
+
|
|
55
|
+
auth_session_id: str | None = None
|
|
56
|
+
auth_subject_id: str | None = None # user_id or service_id
|
|
57
|
+
auth_subject_type: JwtAuthSubjectType | None = None
|
|
58
|
+
|
|
59
|
+
iam_enabled: bool = False
|
|
60
|
+
iam_revoked_at: datetime.datetime | None = None
|
|
61
|
+
iam_role: str | None = None
|
|
62
|
+
iam_scopes: list[str] | None = None
|
|
63
|
+
|
|
64
|
+
raw_claims: dict[str, Any] | None = None
|
|
65
|
+
|
|
66
|
+
def check_graphql_permissions(self, *permissions: GraphQLPermission) -> "JwtAuthContext":
|
|
67
|
+
for perm in permissions:
|
|
68
|
+
if not perm.has_permission(source=None, info=None, auth_ctx=self): # type: ignore[arg-type]
|
|
69
|
+
raise perm.graphql_client_error_class(*perm.graphql_client_error_args()) # type: ignore[missing-argument]
|
|
70
|
+
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def check_http_permissions(self, *permissions: GraphQLPermission) -> "JwtAuthContext":
|
|
74
|
+
# Here we'll get the context for the appropriate GraphQL permissions
|
|
75
|
+
# but then raise an HTTP exception rather than a typed GraphQL exception
|
|
76
|
+
for perm in permissions:
|
|
77
|
+
try:
|
|
78
|
+
if not perm.has_permission(source=None, info=None, auth_ctx=self): # type: ignore[arg-type]
|
|
79
|
+
raise HTTPException(status_code=401)
|
|
80
|
+
except GraphQLClientError as e:
|
|
81
|
+
raise HTTPException(status_code=401) from e
|
|
82
|
+
except GraphQLServerError as e:
|
|
83
|
+
raise HTTPException(status_code=500) from e
|
|
84
|
+
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def current_auth_context() -> "JwtAuthContext":
|
|
89
|
+
return _current_auth_context.get()
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def set_current_auth_context(auth_ctx: "JwtAuthContext") -> None:
|
|
93
|
+
_current_auth_context.set(auth_ctx)
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _create_service_context(claims) -> "JwtAuthContext":
|
|
97
|
+
if claims is None:
|
|
98
|
+
_logger.warning("No claims found in service JWT")
|
|
99
|
+
return JwtAuthContext(has_token=False)
|
|
100
|
+
elif "service_metadata" not in claims:
|
|
101
|
+
_logger.warning("No service_metadata found in service JWT.")
|
|
102
|
+
return JwtAuthContext(has_token=True, is_token_well_formed=False)
|
|
103
|
+
|
|
104
|
+
service_metadata: dict[str, Any] = claims["service_metadata"] # type: ignore[invalid-assignment]
|
|
105
|
+
iam_metadata: dict[str, Any] = service_metadata.get("iam_metadata", {})
|
|
106
|
+
|
|
107
|
+
return JwtAuthContext(
|
|
108
|
+
has_token=True,
|
|
109
|
+
is_token_well_formed=True,
|
|
110
|
+
expires_at=claims["exp"], # type: ignore[invalid-argument-type]
|
|
111
|
+
issued_at=claims["iat"], # type: ignore[invalid-argument-type]
|
|
112
|
+
auth_session_id=None,
|
|
113
|
+
auth_subject_id=service_metadata.get("service_name"),
|
|
114
|
+
auth_subject_type="service",
|
|
115
|
+
iam_enabled=True,
|
|
116
|
+
iam_revoked_at=None,
|
|
117
|
+
iam_role=iam_metadata.get("role"),
|
|
118
|
+
iam_scopes=iam_metadata.get("scopes", []),
|
|
119
|
+
raw_claims=claims,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _create_user_context(claims) -> "JwtAuthContext":
|
|
124
|
+
if claims is None:
|
|
125
|
+
_logger.warning("No claims found in user JWT")
|
|
126
|
+
return JwtAuthContext(has_token=False)
|
|
127
|
+
elif "user_metadata" not in claims:
|
|
128
|
+
_logger.warning("No user_metadata found in user JWT.")
|
|
129
|
+
return JwtAuthContext(has_token=True, is_token_well_formed=False)
|
|
130
|
+
|
|
131
|
+
user_metadata: dict[str, Any] = claims["user_metadata"] # type: ignore[invalid-assignment]
|
|
132
|
+
iam_metadata: dict[str, Any] = user_metadata.get("iam_metadata", {})
|
|
133
|
+
return JwtAuthContext(
|
|
134
|
+
has_token=True,
|
|
135
|
+
is_token_well_formed=True,
|
|
136
|
+
expires_at=claims["exp"], # type: ignore[invalid-argument-type]
|
|
137
|
+
issued_at=claims["iat"], # type: ignore[invalid-argument-type]
|
|
138
|
+
auth_session_id=claims.get("session_id"), # type: ignore[invalid-argument-type]
|
|
139
|
+
auth_subject_id=user_metadata.get("subject_id"),
|
|
140
|
+
auth_subject_type="user",
|
|
141
|
+
iam_enabled=iam_metadata.get("enabled", False),
|
|
142
|
+
iam_revoked_at=iam_metadata.get("revoked_at"),
|
|
143
|
+
iam_role=iam_metadata.get("role"),
|
|
144
|
+
iam_scopes=iam_metadata.get("scopes", []),
|
|
145
|
+
raw_claims=claims,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def from_jwt(jwt_: JWT) -> "JwtAuthContext":
|
|
150
|
+
if not jwt_._token:
|
|
151
|
+
return JwtAuthContext(has_token=False)
|
|
152
|
+
|
|
153
|
+
claims = jwt_.get_raw_jwt()
|
|
154
|
+
if claims is None:
|
|
155
|
+
_logger.warning("No claims found in JWT")
|
|
156
|
+
return JwtAuthContext(has_token=False)
|
|
157
|
+
|
|
158
|
+
if "user_metadata" in claims:
|
|
159
|
+
return JwtAuthContext._create_user_context(claims)
|
|
160
|
+
elif "service_metadata" in claims:
|
|
161
|
+
return JwtAuthContext._create_service_context(claims)
|
|
162
|
+
|
|
163
|
+
_logger.warning(
|
|
164
|
+
"No user_metadata nor kid found in JWT. We do not know if this is a user or a service"
|
|
165
|
+
)
|
|
166
|
+
return JwtAuthContext(has_token=True, is_token_well_formed=False)
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def from_service_request(
|
|
170
|
+
request: Request,
|
|
171
|
+
algorithm: str,
|
|
172
|
+
public_key: str,
|
|
173
|
+
) -> "JwtAuthContext":
|
|
174
|
+
jwt_ = extract_jwt_from_request(request)
|
|
175
|
+
jwt_._algorithm = algorithm
|
|
176
|
+
jwt_._public_key = public_key
|
|
177
|
+
|
|
178
|
+
claims = jwt_.get_raw_jwt()
|
|
179
|
+
return JwtAuthContext._create_service_context(claims)
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def from_user_request(request: Request) -> "JwtAuthContext":
|
|
183
|
+
jwt_ = extract_jwt_from_request(request)
|
|
184
|
+
|
|
185
|
+
claims = jwt_.get_raw_jwt()
|
|
186
|
+
return JwtAuthContext._create_user_context(claims)
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def from_token(token: str) -> "JwtAuthContext":
|
|
190
|
+
jwt_: JWT = convert_token_to_jwt(token)
|
|
191
|
+
return JwtAuthContext.from_jwt(jwt_)
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def peek(request: Request) -> dict | None:
|
|
195
|
+
jwt_: JWT = extract_jwt_from_request(request)
|
|
196
|
+
if jwt_._token is None:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
headers = jwt_.get_unverified_jwt_headers()
|
|
200
|
+
return headers
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
from apppy.auth.jwt import JwtAuthContext
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FakeJWT:
|
|
7
|
+
"""
|
|
8
|
+
Minimal stub for the JWT class used by JwtAuthContext.
|
|
9
|
+
- Accepts _token to simulate "has token" vs not.
|
|
10
|
+
- Returns the provided claims from get_raw_jwt().
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, *, _token=None, claims=None, **kwargs):
|
|
14
|
+
self._token = _token
|
|
15
|
+
self._claims = claims
|
|
16
|
+
|
|
17
|
+
def get_raw_jwt(self):
|
|
18
|
+
return self._claims
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _claims_base(exp=1111111111, iat=1111110000, **extra):
|
|
22
|
+
c = {"exp": exp, "iat": iat}
|
|
23
|
+
c.update(extra)
|
|
24
|
+
return c
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_jwt_empty():
|
|
28
|
+
ctx = JwtAuthContext()
|
|
29
|
+
assert ctx.has_token is False
|
|
30
|
+
assert ctx.is_token_well_formed is False
|
|
31
|
+
assert ctx.expires_at == 0
|
|
32
|
+
assert ctx.issued_at == 0
|
|
33
|
+
# sanity: optional fields default to None
|
|
34
|
+
assert ctx.auth_session_id is None
|
|
35
|
+
assert ctx.auth_subject_id is None
|
|
36
|
+
assert ctx.auth_subject_type is None
|
|
37
|
+
assert ctx.iam_enabled is False
|
|
38
|
+
|
|
39
|
+
assert ctx.iam_revoked_at is None
|
|
40
|
+
assert ctx.iam_role is None
|
|
41
|
+
assert ctx.iam_scopes is None
|
|
42
|
+
|
|
43
|
+
assert ctx.raw_claims is None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_jwt_is_unauthenticated_when_no_token():
|
|
47
|
+
jwt = FakeJWT(_token=None, claims=None)
|
|
48
|
+
ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
|
|
49
|
+
assert ctx.has_token is False
|
|
50
|
+
assert ctx.is_token_well_formed is False
|
|
51
|
+
assert ctx.iam_enabled is False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_jwt_is_unauthenticated_when_no_claims():
|
|
55
|
+
jwt = FakeJWT(_token="token", claims=None)
|
|
56
|
+
ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
|
|
57
|
+
assert ctx.has_token is False
|
|
58
|
+
assert ctx.is_token_well_formed is False
|
|
59
|
+
assert ctx.iam_enabled is False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_jwt_is_unauthenticated_when_missing_user_metadata():
|
|
63
|
+
claims = _claims_base() # no "user_metadata" key
|
|
64
|
+
jwt = FakeJWT(_token="token", claims=claims)
|
|
65
|
+
ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
|
|
66
|
+
assert ctx.has_token is True
|
|
67
|
+
assert ctx.is_token_well_formed is False
|
|
68
|
+
assert ctx.iam_enabled is False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_service_jwt_is_unauthenticated_when_iam_is_disabled():
|
|
72
|
+
claims = _claims_base(
|
|
73
|
+
user_metadata={
|
|
74
|
+
"subject_type": "service",
|
|
75
|
+
"iam_metadata": {
|
|
76
|
+
"enabled": False,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
jwt = FakeJWT(_token="token", claims=claims)
|
|
81
|
+
ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
|
|
82
|
+
assert ctx.has_token is True
|
|
83
|
+
assert ctx.is_token_well_formed is True
|
|
84
|
+
assert ctx.iam_enabled is False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_service_jwt_is_unauthenticated_when_iam_is_revoked():
|
|
88
|
+
claims = _claims_base(
|
|
89
|
+
user_metadata={
|
|
90
|
+
"subject_type": "service",
|
|
91
|
+
"iam_metadata": {"enabled": True, "revoked_at": datetime.datetime.now()},
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
jwt = FakeJWT(_token="token", claims=claims)
|
|
95
|
+
ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
|
|
96
|
+
assert ctx.has_token is True
|
|
97
|
+
assert ctx.is_token_well_formed is True
|
|
98
|
+
assert ctx.iam_enabled is True
|
|
99
|
+
assert ctx.iam_revoked_at is not None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_service_jwt_subject_type():
|
|
103
|
+
claims = _claims_base(
|
|
104
|
+
service_metadata={
|
|
105
|
+
"subject_type": "service",
|
|
106
|
+
"iam_metadata": {
|
|
107
|
+
"enabled": True,
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
jwt = FakeJWT(_token="token", claims=claims)
|
|
112
|
+
|
|
113
|
+
ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
|
|
114
|
+
assert ctx.auth_subject_type == "service"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_user_jwt_is_unauthenticated_when_iam_is_disabled():
|
|
118
|
+
claims = _claims_base(
|
|
119
|
+
user_metadata={
|
|
120
|
+
"subject_type": "user",
|
|
121
|
+
"iam_metadata": {
|
|
122
|
+
"enabled": False,
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
jwt = FakeJWT(_token="token", claims=claims)
|
|
127
|
+
ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
|
|
128
|
+
assert ctx.has_token is True
|
|
129
|
+
assert ctx.is_token_well_formed is True
|
|
130
|
+
assert ctx.iam_enabled is False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_user_jwt_is_unauthenticated_when_iam_is_revoked():
|
|
134
|
+
claims = _claims_base(
|
|
135
|
+
user_metadata={
|
|
136
|
+
"subject_type": "user",
|
|
137
|
+
"iam_metadata": {"enabled": True, "revoked_at": datetime.datetime.now()},
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
jwt = FakeJWT(_token="token", claims=claims)
|
|
141
|
+
ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
|
|
142
|
+
assert ctx.has_token is True
|
|
143
|
+
assert ctx.is_token_well_formed is True
|
|
144
|
+
assert ctx.iam_enabled is True
|
|
145
|
+
assert ctx.iam_revoked_at is not None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_user_jwt_subject_type():
|
|
149
|
+
claims = _claims_base(
|
|
150
|
+
user_metadata={
|
|
151
|
+
"subject_type": "user",
|
|
152
|
+
"iam_metadata": {
|
|
153
|
+
"enabled": True,
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
jwt = FakeJWT(_token="token", claims=claims)
|
|
158
|
+
|
|
159
|
+
ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
|
|
160
|
+
assert ctx.auth_subject_type == "user"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from authlib.integrations.starlette_client import OAuth
|
|
2
|
+
|
|
3
|
+
from apppy.auth.errors.oauth import UnknownOAuthProviderError
|
|
4
|
+
from apppy.env import EnvSettings
|
|
5
|
+
from apppy.logger import WithLogger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OAuthRegistrySettings(EnvSettings):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OAuthRegistry(OAuth, WithLogger):
|
|
13
|
+
"""
|
|
14
|
+
Wrapper about the Starlette OAuth integration to allow for
|
|
15
|
+
any custom settings or logic to be added.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, settings: OAuthRegistrySettings) -> None:
|
|
19
|
+
super().__init__()
|
|
20
|
+
self._settings = settings
|
|
21
|
+
|
|
22
|
+
def load_client(self, provider: str) -> OAuth:
|
|
23
|
+
oauth_client = self.create_client(provider)
|
|
24
|
+
if oauth_client is None:
|
|
25
|
+
raise UnknownOAuthProviderError(provider)
|
|
26
|
+
|
|
27
|
+
return oauth_client
|
|
28
|
+
|
|
29
|
+
def register(self, name, overwrite=False, **kwargs):
|
|
30
|
+
self._logger.info("Registering OAuth provider", extra={"provider": name})
|
|
31
|
+
super().register(name, overwrite=overwrite, **kwargs)
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from apppy.auth.jwt import JwtAuthContext
|
|
6
|
+
from apppy.fastql.annotation import fastql_type_error
|
|
7
|
+
from apppy.fastql.errors import GraphQLClientError, GraphQLServerError
|
|
8
|
+
from apppy.fastql.permissions import GraphQLPermission
|
|
9
|
+
from apppy.logger import WithLogger
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _check_preprocessing_errors(permission: GraphQLPermission, auth_ctx: JwtAuthContext) -> None:
|
|
13
|
+
if auth_ctx.preprocessing_error is not None:
|
|
14
|
+
if isinstance(auth_ctx.preprocessing_error, GraphQLClientError):
|
|
15
|
+
permission._error_code = auth_ctx.preprocessing_error.code # type: ignore[attr-defined]
|
|
16
|
+
raise permission.graphql_client_error_class(*permission.graphql_client_error_args())
|
|
17
|
+
else:
|
|
18
|
+
raise permission.graphql_server_error_class(auth_ctx.preprocessing_error.code)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _extract_auth_context(permission: GraphQLPermission, info, **kwargs) -> JwtAuthContext:
|
|
22
|
+
auth_ctx: JwtAuthContext = kwargs.get("auth_ctx") # type: ignore[assignment]
|
|
23
|
+
if auth_ctx is not None:
|
|
24
|
+
return auth_ctx
|
|
25
|
+
|
|
26
|
+
if not info:
|
|
27
|
+
permission._logger.error(
|
|
28
|
+
"No context or info provided while attempting to check permission",
|
|
29
|
+
extra={"permission": type(permission).__name__},
|
|
30
|
+
)
|
|
31
|
+
raise permission.graphql_server_error_class("missing_context_and_info")
|
|
32
|
+
|
|
33
|
+
return info.context.auth
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@fastql_type_error
|
|
37
|
+
class AuthenticationServerError(GraphQLServerError):
|
|
38
|
+
"""Error raised when authentication fails due to an error on the server side"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, code) -> None:
|
|
41
|
+
super().__init__(code)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@fastql_type_error
|
|
45
|
+
class AuthenticatedServiceOrUserRequiredError(GraphQLClientError):
|
|
46
|
+
"""Error raised when authentication is required but it is missing"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, code) -> None:
|
|
49
|
+
super().__init__(code)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class IsServiceOrUser(GraphQLPermission, WithLogger):
|
|
53
|
+
graphql_client_error_class = AuthenticatedServiceOrUserRequiredError
|
|
54
|
+
graphql_server_error_class = AuthenticationServerError
|
|
55
|
+
|
|
56
|
+
def __init__(self):
|
|
57
|
+
self._error_code: str | None = None
|
|
58
|
+
|
|
59
|
+
def has_permission(self, source, info, **kwargs) -> bool:
|
|
60
|
+
auth_ctx: JwtAuthContext = _extract_auth_context(self, info, **kwargs)
|
|
61
|
+
_check_preprocessing_errors(self, auth_ctx)
|
|
62
|
+
|
|
63
|
+
is_authenticated, self._error_code = IsServiceOrUser.is_context_authenticated(auth_ctx)
|
|
64
|
+
return is_authenticated
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def is_context_authenticated(auth_ctx: JwtAuthContext) -> tuple[bool, str | None]:
|
|
68
|
+
if not auth_ctx.has_token:
|
|
69
|
+
return (False, "token_is_missing")
|
|
70
|
+
if not auth_ctx.is_token_well_formed:
|
|
71
|
+
return (False, "token_is_not_well_formed")
|
|
72
|
+
|
|
73
|
+
now = int(datetime.datetime.now().timestamp())
|
|
74
|
+
if now > auth_ctx.expires_at:
|
|
75
|
+
return (False, "token_is_expired")
|
|
76
|
+
if not auth_ctx.iam_enabled:
|
|
77
|
+
return (False, "iam_is_disabled")
|
|
78
|
+
if auth_ctx.iam_revoked_at is not None:
|
|
79
|
+
return (False, "iam_is_revoked")
|
|
80
|
+
|
|
81
|
+
return (True, None)
|
|
82
|
+
|
|
83
|
+
def graphql_client_error_args(self) -> tuple[Any, ...]:
|
|
84
|
+
return (self._error_code,)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@fastql_type_error
|
|
88
|
+
class AuthenticatedUserRequiredError(GraphQLClientError):
|
|
89
|
+
"""Error raised when a user authentication is required but it is missing"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, code: str) -> None:
|
|
92
|
+
super().__init__(code)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class IsUser(GraphQLPermission, WithLogger):
|
|
96
|
+
graphql_client_error_class = AuthenticatedUserRequiredError
|
|
97
|
+
graphql_server_error_class = AuthenticationServerError
|
|
98
|
+
|
|
99
|
+
def __init__(self):
|
|
100
|
+
self._error_code: str | None = None
|
|
101
|
+
|
|
102
|
+
def has_permission(self, source, info, **kwargs) -> bool:
|
|
103
|
+
auth_ctx: JwtAuthContext = _extract_auth_context(self, info, **kwargs)
|
|
104
|
+
_check_preprocessing_errors(self, auth_ctx)
|
|
105
|
+
|
|
106
|
+
is_authenticated, self._error_code = IsUser.is_context_user_authenticated(auth_ctx)
|
|
107
|
+
return is_authenticated
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def is_context_user_authenticated(auth_ctx: JwtAuthContext) -> tuple[bool, str | None]:
|
|
111
|
+
is_authenticated, error_code = IsServiceOrUser.is_context_authenticated(auth_ctx)
|
|
112
|
+
if not is_authenticated:
|
|
113
|
+
return (is_authenticated, error_code)
|
|
114
|
+
|
|
115
|
+
if auth_ctx.auth_subject_type != "user":
|
|
116
|
+
return (False, "not_authenticated_as_user")
|
|
117
|
+
|
|
118
|
+
return (True, None)
|
|
119
|
+
|
|
120
|
+
def graphql_client_error_args(self) -> tuple[Any, ...]:
|
|
121
|
+
return (self._error_code,)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@fastql_type_error
|
|
125
|
+
class AuthenticatedServiceRequiredError(GraphQLClientError):
|
|
126
|
+
"""Error raised when a service authentication is required but it is missing"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, code: str) -> None:
|
|
129
|
+
super().__init__(code)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class IsService(GraphQLPermission):
|
|
133
|
+
graphql_client_error_class = AuthenticatedServiceRequiredError
|
|
134
|
+
graphql_server_error_class = AuthenticationServerError
|
|
135
|
+
|
|
136
|
+
def __init__(self):
|
|
137
|
+
self._error_code: str | None = None
|
|
138
|
+
|
|
139
|
+
def has_permission(self, source, info, **kwargs) -> bool:
|
|
140
|
+
auth_ctx: JwtAuthContext = _extract_auth_context(self, info, **kwargs)
|
|
141
|
+
_check_preprocessing_errors(self, auth_ctx)
|
|
142
|
+
|
|
143
|
+
is_authenticated, self._error_code = IsService.is_context_service_authenticated(auth_ctx)
|
|
144
|
+
return is_authenticated
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def is_context_service_authenticated(auth_ctx: JwtAuthContext) -> tuple[bool, str | None]:
|
|
148
|
+
is_authenticated, error_code = IsServiceOrUser.is_context_authenticated(auth_ctx)
|
|
149
|
+
if not is_authenticated:
|
|
150
|
+
return (is_authenticated, error_code)
|
|
151
|
+
|
|
152
|
+
if auth_ctx.auth_subject_type != "service":
|
|
153
|
+
return (False, "not_authenticated_as_service")
|
|
154
|
+
|
|
155
|
+
return (True, None)
|
|
156
|
+
|
|
157
|
+
def graphql_client_error_args(self) -> tuple[Any, ...]:
|
|
158
|
+
return (self._error_code,)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@fastql_type_error
|
|
162
|
+
class AuthorizationServerError(GraphQLServerError):
|
|
163
|
+
"""Error raised when authorization fails due to an error on the server side"""
|
|
164
|
+
|
|
165
|
+
def __init__(self, code) -> None:
|
|
166
|
+
super().__init__(code)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@fastql_type_error
|
|
170
|
+
class AuthorizedRoleRequiredError(GraphQLClientError):
|
|
171
|
+
"""Error raised when an authorization role is required but it is missing"""
|
|
172
|
+
|
|
173
|
+
permitted_role: str
|
|
174
|
+
|
|
175
|
+
def __init__(self, permitted_role: str) -> None:
|
|
176
|
+
super().__init__("authorization_role_required")
|
|
177
|
+
self.permitted_role = permitted_role
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class HasRole(GraphQLPermission, WithLogger):
|
|
181
|
+
graphql_client_error_class = AuthorizedRoleRequiredError
|
|
182
|
+
graphql_server_error_class = AuthorizationServerError
|
|
183
|
+
|
|
184
|
+
def __init__(self, role: str):
|
|
185
|
+
self._role = role
|
|
186
|
+
|
|
187
|
+
def has_permission(self, source, info, **kwargs) -> bool:
|
|
188
|
+
auth_ctx: JwtAuthContext = _extract_auth_context(self, info, **kwargs)
|
|
189
|
+
_check_preprocessing_errors(self, auth_ctx)
|
|
190
|
+
|
|
191
|
+
return bool(auth_ctx.iam_role and self._role == auth_ctx.iam_role)
|
|
192
|
+
|
|
193
|
+
def graphql_client_error_args(self) -> tuple[Any, ...]:
|
|
194
|
+
return (self._role,)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@fastql_type_error
|
|
198
|
+
class AuthorizedScopeRequiredError(GraphQLClientError):
|
|
199
|
+
"""Error raised when an authorization scope is required but it is missing"""
|
|
200
|
+
|
|
201
|
+
permitted_scope: str
|
|
202
|
+
|
|
203
|
+
def __init__(self, permitted_scope: str) -> None:
|
|
204
|
+
super().__init__("authorization_scope_required")
|
|
205
|
+
self.permitted_scope = permitted_scope
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class HasScope(GraphQLPermission, WithLogger):
|
|
209
|
+
graphql_client_error_class = AuthorizedScopeRequiredError
|
|
210
|
+
graphql_server_error_class = AuthorizationServerError
|
|
211
|
+
|
|
212
|
+
def __init__(self, scope: str):
|
|
213
|
+
self._scope = scope
|
|
214
|
+
|
|
215
|
+
def has_permission(self, source, info, **kwargs) -> bool:
|
|
216
|
+
auth_ctx: JwtAuthContext = _extract_auth_context(self, info, **kwargs)
|
|
217
|
+
_check_preprocessing_errors(self, auth_ctx)
|
|
218
|
+
|
|
219
|
+
return self._check_scope(auth_ctx)
|
|
220
|
+
|
|
221
|
+
def graphql_client_error_args(self) -> tuple[Any, ...]:
|
|
222
|
+
return (self._scope,)
|
|
223
|
+
|
|
224
|
+
def _check_scope(self, auth_ctx: JwtAuthContext) -> bool:
|
|
225
|
+
# Allow hierarchical scopes like "users:read" implied by "users:*"
|
|
226
|
+
if not auth_ctx.iam_scopes:
|
|
227
|
+
return False
|
|
228
|
+
if self._scope in auth_ctx.iam_scopes:
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
prefix = self._scope.split(":")[0] + ":*"
|
|
232
|
+
return prefix in auth_ctx.iam_scopes
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@fastql_type_error
|
|
236
|
+
class AuthorizedRoleOrScopeRequiredError(GraphQLClientError):
|
|
237
|
+
"""Error raised when an authorization role is required but it is missing"""
|
|
238
|
+
|
|
239
|
+
permitted_roles: list[str]
|
|
240
|
+
permitted_scopes: list[str]
|
|
241
|
+
|
|
242
|
+
def __init__(self, permitted_roles: list[str], permitted_scopes: list[str]) -> None:
|
|
243
|
+
super().__init__("authorization_role_or_scope_required")
|
|
244
|
+
self.permitted_roles = permitted_roles
|
|
245
|
+
self.permitted_scopes = permitted_scopes
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class HasRoleOrScope(GraphQLPermission, WithLogger):
|
|
249
|
+
graphql_client_error_class = AuthorizedRoleOrScopeRequiredError
|
|
250
|
+
graphql_server_error_class = AuthorizationServerError
|
|
251
|
+
|
|
252
|
+
def __init__(self, roles: Sequence[str | HasRole] = (), scopes: Sequence[str | HasScope] = ()):
|
|
253
|
+
self._roles = roles
|
|
254
|
+
self._role_permissions: list[HasRole] = [
|
|
255
|
+
role if isinstance(role, HasRole) else HasRole(role) for role in roles
|
|
256
|
+
]
|
|
257
|
+
self._scopes = scopes
|
|
258
|
+
self._scope_permissions: list[HasScope] = [
|
|
259
|
+
scope if isinstance(scope, HasScope) else HasScope(scope) for scope in scopes
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
def has_permission(self, source, info, **kwargs) -> bool:
|
|
263
|
+
auth_ctx: JwtAuthContext = _extract_auth_context(self, info, **kwargs)
|
|
264
|
+
_check_preprocessing_errors(self, auth_ctx)
|
|
265
|
+
|
|
266
|
+
has_role: bool = any(
|
|
267
|
+
perm.has_permission(source, info, auth_ctx=auth_ctx) for perm in self._role_permissions
|
|
268
|
+
)
|
|
269
|
+
has_scope: bool = False
|
|
270
|
+
if not has_role:
|
|
271
|
+
has_scope = any(
|
|
272
|
+
perm.has_permission(source, info, auth_ctx=auth_ctx)
|
|
273
|
+
for perm in self._scope_permissions
|
|
274
|
+
)
|
|
275
|
+
return bool(has_role or has_scope)
|
|
276
|
+
|
|
277
|
+
def graphql_client_error_args(self) -> tuple[Any, ...]:
|
|
278
|
+
return (
|
|
279
|
+
self._roles,
|
|
280
|
+
self._scopes,
|
|
281
|
+
)
|