tba-app-backend-my-account 0.4.0.dev2__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.
- tba_app_backend_my_account-0.4.0.dev2/PKG-INFO +44 -0
- tba_app_backend_my_account-0.4.0.dev2/README.md +18 -0
- tba_app_backend_my_account-0.4.0.dev2/pyproject.toml +29 -0
- tba_app_backend_my_account-0.4.0.dev2/src/tba_app_backend_my_account/__init__.py +35 -0
- tba_app_backend_my_account-0.4.0.dev2/src/tba_app_backend_my_account/app.py +132 -0
- tba_app_backend_my_account-0.4.0.dev2/src/tba_app_backend_my_account/common.py +58 -0
- tba_app_backend_my_account-0.4.0.dev2/src/tba_app_backend_my_account/consents.py +164 -0
- tba_app_backend_my_account-0.4.0.dev2/src/tba_app_backend_my_account/contract.py +82 -0
- tba_app_backend_my_account-0.4.0.dev2/src/tba_app_backend_my_account/openapi.py +52 -0
- tba_app_backend_my_account-0.4.0.dev2/src/tba_app_backend_my_account/profiles.py +177 -0
- tba_app_backend_my_account-0.4.0.dev2/src/tba_app_backend_my_account/py.typed +0 -0
- tba_app_backend_my_account-0.4.0.dev2/src/tba_app_backend_my_account/routes.py +18 -0
- tba_app_backend_my_account-0.4.0.dev2/src/tba_app_backend_my_account/sessions.py +102 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tba-app-backend-my-account
|
|
3
|
+
Version: 0.4.0.dev2
|
|
4
|
+
Summary: End-user account self-service backend application for TBA.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.10,<3.15
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Dist: pydantic (==2.12.5)
|
|
14
|
+
Requires-Dist: tba-app-backend-core (==0.4.0.dev2)
|
|
15
|
+
Requires-Dist: tba-auth (==0.4.0.dev2)
|
|
16
|
+
Requires-Dist: tba-capability-account-self-service (==0.4.0.dev2)
|
|
17
|
+
Requires-Dist: tba-contracts-identity (==0.4.0.dev2)
|
|
18
|
+
Requires-Dist: tba-router-oidc-discovery (==0.4.0.dev2)
|
|
19
|
+
Requires-Dist: tba-server-identity (==0.4.0.dev2)
|
|
20
|
+
Requires-Dist: tigrbl (==0.4.5.dev8)
|
|
21
|
+
Project-URL: Homepage, https://github.com/tigrbl/tigrbl_auth
|
|
22
|
+
Project-URL: Repository, https://github.com/tigrbl/tigrbl_auth
|
|
23
|
+
Project-URL: Source, https://github.com/tigrbl/tigrbl_auth/tree/master/pkgs/90-backend-apps/tba-app-backend-my-account
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# tba-app-backend-my-account
|
|
27
|
+
|
|
28
|
+
End-user self-service account API front door for TBA.
|
|
29
|
+
|
|
30
|
+
This package exposes the authenticated current subject's account surface:
|
|
31
|
+
|
|
32
|
+
- `GET /account/profile`
|
|
33
|
+
- `PATCH /account/profile`
|
|
34
|
+
- `GET /account/sessions`
|
|
35
|
+
- `DELETE /account/sessions/{session_id}`
|
|
36
|
+
- `GET /account/authorized-apps`
|
|
37
|
+
- `DELETE /account/authorized-apps/{client_id}`
|
|
38
|
+
- `GET /account/consents`
|
|
39
|
+
- `DELETE /account/consents/{consent_id}`
|
|
40
|
+
- `POST /account/password/change`
|
|
41
|
+
|
|
42
|
+
It does not expose platform administration, tenant-wide user management, RPC,
|
|
43
|
+
or table CRUD. All account operations are scoped to the authenticated end user.
|
|
44
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# tba-app-backend-my-account
|
|
2
|
+
|
|
3
|
+
End-user self-service account API front door for TBA.
|
|
4
|
+
|
|
5
|
+
This package exposes the authenticated current subject's account surface:
|
|
6
|
+
|
|
7
|
+
- `GET /account/profile`
|
|
8
|
+
- `PATCH /account/profile`
|
|
9
|
+
- `GET /account/sessions`
|
|
10
|
+
- `DELETE /account/sessions/{session_id}`
|
|
11
|
+
- `GET /account/authorized-apps`
|
|
12
|
+
- `DELETE /account/authorized-apps/{client_id}`
|
|
13
|
+
- `GET /account/consents`
|
|
14
|
+
- `DELETE /account/consents/{consent_id}`
|
|
15
|
+
- `POST /account/password/change`
|
|
16
|
+
|
|
17
|
+
It does not expose platform administration, tenant-wide user management, RPC,
|
|
18
|
+
or table CRUD. All account operations are scoped to the authenticated end user.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tba-app-backend-my-account"
|
|
3
|
+
version = "0.4.0.dev2"
|
|
4
|
+
description = "End-user account self-service backend application for TBA."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10,<3.15"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"pydantic==2.12.5",
|
|
10
|
+
"tba-auth==0.4.0.dev2",
|
|
11
|
+
"tba-app-backend-core==0.4.0.dev2",
|
|
12
|
+
"tigrbl==0.4.5.dev8",
|
|
13
|
+
"tba-capability-account-self-service==0.4.0.dev2",
|
|
14
|
+
"tba-contracts-identity==0.4.0.dev2",
|
|
15
|
+
"tba-server-identity==0.4.0.dev2",
|
|
16
|
+
"tba-router-oidc-discovery==0.4.0.dev2",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/tigrbl/tigrbl_auth"
|
|
21
|
+
Repository = "https://github.com/tigrbl/tigrbl_auth"
|
|
22
|
+
Source = "https://github.com/tigrbl/tigrbl_auth/tree/master/pkgs/90-backend-apps/tba-app-backend-my-account"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["poetry-core>=1.0.0"]
|
|
26
|
+
build-backend = "poetry.core.masonry.api"
|
|
27
|
+
|
|
28
|
+
[tool.poetry]
|
|
29
|
+
packages = [{ include = "tba_app_backend_my_account", from = "src" }]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""My Account backend application package for TBA."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .common import MyAccountMutationOut
|
|
6
|
+
from .contract import MY_ACCOUNT_BACKEND_APP_CONTRACT, MyAccountBackendAppContract
|
|
7
|
+
from .profiles import (
|
|
8
|
+
MyAccountPasswordChangeIn,
|
|
9
|
+
MyAccountProfileOut,
|
|
10
|
+
MyAccountProfileUpdateIn,
|
|
11
|
+
)
|
|
12
|
+
from .sessions import MyAccountSessionOut
|
|
13
|
+
|
|
14
|
+
PRODUCT_SURFACE = "my-account-app"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def __getattr__(name: str):
|
|
18
|
+
if name in {"app", "build_app"}:
|
|
19
|
+
from .app import app, build_app
|
|
20
|
+
|
|
21
|
+
return app if name == "app" else build_app
|
|
22
|
+
raise AttributeError(name)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"MY_ACCOUNT_BACKEND_APP_CONTRACT",
|
|
26
|
+
"MyAccountMutationOut",
|
|
27
|
+
"MyAccountPasswordChangeIn",
|
|
28
|
+
"MyAccountBackendAppContract",
|
|
29
|
+
"MyAccountProfileOut",
|
|
30
|
+
"MyAccountProfileUpdateIn",
|
|
31
|
+
"MyAccountSessionOut",
|
|
32
|
+
"PRODUCT_SURFACE",
|
|
33
|
+
"app",
|
|
34
|
+
"build_app",
|
|
35
|
+
]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""ASGI entrypoint for the My Account TBA backend application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
from tba_app_backend_core import build_product_app
|
|
9
|
+
from tba_app_backend_my_account.contract import MY_ACCOUNT_BACKEND_APP_CONTRACT
|
|
10
|
+
from tba_auth.runtime import LazyASGIApplication
|
|
11
|
+
from tba_app_backend_my_account.openapi import patch_my_account_openapi
|
|
12
|
+
from tba_app_backend_my_account.routes import router as account_router
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from tigrbl import TigrblApp
|
|
16
|
+
|
|
17
|
+
PRODUCT_SURFACE = "my-account-app"
|
|
18
|
+
DEFAULT_CORS_ORIGINS = ("http://localhost:3019", "http://127.0.0.1:3019")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MyAccountCors:
|
|
22
|
+
"""Small ASGI CORS wrapper for the standalone My Account/UIX demo surface."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self, app: Any, origins: tuple[str, ...] = DEFAULT_CORS_ORIGINS
|
|
26
|
+
) -> None:
|
|
27
|
+
self.app = app
|
|
28
|
+
self.origins = origins
|
|
29
|
+
|
|
30
|
+
def __getattr__(self, name: str) -> Any:
|
|
31
|
+
return getattr(self.app, name)
|
|
32
|
+
|
|
33
|
+
async def __call__(
|
|
34
|
+
self,
|
|
35
|
+
scope: dict[str, Any],
|
|
36
|
+
receive: Callable[[], Awaitable[dict[str, Any]]],
|
|
37
|
+
send: Callable[[dict[str, Any]], Awaitable[None]],
|
|
38
|
+
) -> None:
|
|
39
|
+
if scope.get("type") != "http":
|
|
40
|
+
await self.app(scope, receive, send)
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
headers = {
|
|
44
|
+
key.decode("latin-1").lower(): value.decode("latin-1")
|
|
45
|
+
for key, value in scope.get("headers", [])
|
|
46
|
+
}
|
|
47
|
+
origin = headers.get("origin")
|
|
48
|
+
allowed_origin = origin if origin in self.origins else None
|
|
49
|
+
|
|
50
|
+
if str(scope.get("method", "")).upper() == "OPTIONS" and allowed_origin:
|
|
51
|
+
await send(
|
|
52
|
+
{
|
|
53
|
+
"type": "http.response.start",
|
|
54
|
+
"status": 204,
|
|
55
|
+
"headers": self._cors_headers(allowed_origin)
|
|
56
|
+
+ [
|
|
57
|
+
(
|
|
58
|
+
b"access-control-allow-methods",
|
|
59
|
+
b"GET,PATCH,POST,DELETE,OPTIONS",
|
|
60
|
+
),
|
|
61
|
+
(
|
|
62
|
+
b"access-control-allow-headers",
|
|
63
|
+
headers.get(
|
|
64
|
+
"access-control-request-headers",
|
|
65
|
+
"content-type,authorization",
|
|
66
|
+
).encode("latin-1"),
|
|
67
|
+
),
|
|
68
|
+
(b"content-length", b"0"),
|
|
69
|
+
],
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
await send({"type": "http.response.body", "body": b""})
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
async def cors_send(message: dict[str, Any]) -> None:
|
|
76
|
+
if message.get("type") == "http.response.start" and allowed_origin:
|
|
77
|
+
message = dict(message)
|
|
78
|
+
response_headers = list(message.get("headers", []))
|
|
79
|
+
response_headers.extend(self._cors_headers(allowed_origin))
|
|
80
|
+
message["headers"] = response_headers
|
|
81
|
+
await send(message)
|
|
82
|
+
|
|
83
|
+
await self.app(scope, receive, cors_send)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _cors_headers(origin: str) -> list[tuple[bytes, bytes]]:
|
|
87
|
+
return [
|
|
88
|
+
(b"access-control-allow-origin", origin.encode("latin-1")),
|
|
89
|
+
(b"access-control-allow-credentials", b"true"),
|
|
90
|
+
(b"vary", b"Origin"),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _default_settings() -> object:
|
|
95
|
+
from tba_auth.config.settings import Settings
|
|
96
|
+
|
|
97
|
+
settings = Settings()
|
|
98
|
+
env_overrides = {
|
|
99
|
+
"issuer": os.environ.get("AUTHN_ISSUER"),
|
|
100
|
+
"protected_resource_identifier": os.environ.get(
|
|
101
|
+
"TBA_AUTH_PROTECTED_RESOURCE_IDENTIFIER"
|
|
102
|
+
),
|
|
103
|
+
"deployment_profile": os.environ.get("TBA_AUTH_PROFILE"),
|
|
104
|
+
"require_tls": os.environ.get("TBA_AUTH_REQUIRE_TLS"),
|
|
105
|
+
"session_cookie_force_secure": os.environ.get(
|
|
106
|
+
"TBA_AUTH_SESSION_COOKIE_FORCE_SECURE"
|
|
107
|
+
),
|
|
108
|
+
}
|
|
109
|
+
for name, value in env_overrides.items():
|
|
110
|
+
if value in {None, ""}:
|
|
111
|
+
continue
|
|
112
|
+
current = getattr(settings, name, None)
|
|
113
|
+
if isinstance(current, bool):
|
|
114
|
+
value = str(value).lower() in {"1", "true", "yes", "on"}
|
|
115
|
+
setattr(settings, name, value)
|
|
116
|
+
return settings
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_app(settings_obj: object | None = None) -> "TigrblApp":
|
|
120
|
+
if settings_obj is None:
|
|
121
|
+
settings_obj = _default_settings()
|
|
122
|
+
app = build_product_app(
|
|
123
|
+
PRODUCT_SURFACE, settings_obj, contract=MY_ACCOUNT_BACKEND_APP_CONTRACT
|
|
124
|
+
)
|
|
125
|
+
app.include_router(account_router)
|
|
126
|
+
app = patch_my_account_openapi(app)
|
|
127
|
+
return MyAccountCors(app)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
app = LazyASGIApplication(build_app)
|
|
131
|
+
|
|
132
|
+
__all__ = ["PRODUCT_SURFACE", "app", "build_app"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Shared HTTP concerns for the My Account API product."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
from tigrbl.requests import Request
|
|
10
|
+
from tigrbl.security import Depends
|
|
11
|
+
from tba_contracts_identity.account_self_service import AccountPrincipal
|
|
12
|
+
from tba_server_identity.account_self_service import (
|
|
13
|
+
current_account_principal,
|
|
14
|
+
get_db,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MyAccountMutationOut(BaseModel):
|
|
19
|
+
status: str
|
|
20
|
+
id: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def current_principal_dependency(
|
|
24
|
+
request: Request,
|
|
25
|
+
authorization: str | None = None,
|
|
26
|
+
api_key: str | None = None,
|
|
27
|
+
dpop: str | None = None,
|
|
28
|
+
db: Any = Depends(get_db),
|
|
29
|
+
) -> AccountPrincipal:
|
|
30
|
+
app_state = getattr(getattr(request, "app", None), "state", None)
|
|
31
|
+
provider = getattr(app_state, "tba_auth_account_principal_provider", None)
|
|
32
|
+
if callable(provider):
|
|
33
|
+
provided = provider(request)
|
|
34
|
+
principal = await provided if inspect.isawaitable(provided) else provided
|
|
35
|
+
if not isinstance(principal, AccountPrincipal):
|
|
36
|
+
raise TypeError("account principal provider returned an invalid principal")
|
|
37
|
+
return principal
|
|
38
|
+
headers = getattr(request, "headers", {})
|
|
39
|
+
authorization = (
|
|
40
|
+
authorization
|
|
41
|
+
or headers.get("Authorization", "")
|
|
42
|
+
or headers.get("authorization", "")
|
|
43
|
+
)
|
|
44
|
+
api_key = api_key or headers.get("x-api-key")
|
|
45
|
+
dpop = dpop or headers.get("DPoP") or headers.get("dpop")
|
|
46
|
+
return await current_account_principal(
|
|
47
|
+
request,
|
|
48
|
+
authorization=authorization,
|
|
49
|
+
api_key=api_key,
|
|
50
|
+
dpop=dpop,
|
|
51
|
+
db=db,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"MyAccountMutationOut",
|
|
57
|
+
"current_principal_dependency",
|
|
58
|
+
]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Current-subject consent and authorized-application HTTP routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from tigrbl import TigrblRouter
|
|
11
|
+
from tigrbl.requests import Request
|
|
12
|
+
from tigrbl.runtime.status import HTTPException
|
|
13
|
+
from tigrbl.security import Depends
|
|
14
|
+
from tba_contracts_identity.account_self_service import (
|
|
15
|
+
AccountConsent,
|
|
16
|
+
AccountNotFoundError,
|
|
17
|
+
AccountPrincipal,
|
|
18
|
+
)
|
|
19
|
+
from tba_server_identity.account_self_service import (
|
|
20
|
+
account_principal,
|
|
21
|
+
account_self_service_for_request,
|
|
22
|
+
get_db,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .common import current_principal_dependency
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
router = TigrblRouter()
|
|
29
|
+
MY_ACCOUNT_TAGS = ["My Account"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MyAccountConsentOut(BaseModel):
|
|
33
|
+
id: UUID
|
|
34
|
+
tenant_id: UUID
|
|
35
|
+
user_id: UUID
|
|
36
|
+
client_id: UUID
|
|
37
|
+
scope: str
|
|
38
|
+
claims: dict[str, Any] | None = None
|
|
39
|
+
state: str
|
|
40
|
+
granted_at: datetime | None = None
|
|
41
|
+
expires_at: datetime | None = None
|
|
42
|
+
revoked_at: datetime | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MyAccountAuthorizedAppOut(BaseModel):
|
|
46
|
+
client_id: UUID
|
|
47
|
+
tenant_id: UUID
|
|
48
|
+
scope: str
|
|
49
|
+
consent_state: str
|
|
50
|
+
granted_at: datetime | None = None
|
|
51
|
+
revoked_at: datetime | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _consent_payload(record: AccountConsent) -> MyAccountConsentOut:
|
|
55
|
+
return MyAccountConsentOut(
|
|
56
|
+
id=record.consent_id,
|
|
57
|
+
tenant_id=record.tenant_id,
|
|
58
|
+
user_id=record.identity_id,
|
|
59
|
+
client_id=record.client_id,
|
|
60
|
+
scope=record.scope,
|
|
61
|
+
claims=dict(record.claims) if record.claims is not None else None,
|
|
62
|
+
state=record.state,
|
|
63
|
+
granted_at=record.granted_at,
|
|
64
|
+
expires_at=record.expires_at,
|
|
65
|
+
revoked_at=record.revoked_at,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _authorized_app_payload(record: AccountConsent) -> MyAccountAuthorizedAppOut:
|
|
70
|
+
return MyAccountAuthorizedAppOut(
|
|
71
|
+
client_id=record.client_id,
|
|
72
|
+
tenant_id=record.tenant_id,
|
|
73
|
+
scope=record.scope,
|
|
74
|
+
consent_state=record.state,
|
|
75
|
+
granted_at=record.granted_at,
|
|
76
|
+
revoked_at=record.revoked_at,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.route(
|
|
81
|
+
"/account/consents",
|
|
82
|
+
methods=["GET"],
|
|
83
|
+
response_model=list[MyAccountConsentOut],
|
|
84
|
+
tags=MY_ACCOUNT_TAGS,
|
|
85
|
+
)
|
|
86
|
+
async def list_account_consents(
|
|
87
|
+
request: Request | None = None,
|
|
88
|
+
current_user: AccountPrincipal | object = Depends(current_principal_dependency),
|
|
89
|
+
db: Any = Depends(get_db),
|
|
90
|
+
) -> list[MyAccountConsentOut]:
|
|
91
|
+
records = await account_self_service_for_request(
|
|
92
|
+
request or object(), db
|
|
93
|
+
).list_consents(account_principal(current_user))
|
|
94
|
+
return [_consent_payload(record) for record in records]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.route(
|
|
98
|
+
"/account/consents/{consent_id}",
|
|
99
|
+
methods=["DELETE"],
|
|
100
|
+
response_model=MyAccountConsentOut,
|
|
101
|
+
tags=MY_ACCOUNT_TAGS,
|
|
102
|
+
)
|
|
103
|
+
async def revoke_account_consent(
|
|
104
|
+
consent_id: str,
|
|
105
|
+
request: Request | None = None,
|
|
106
|
+
current_user: AccountPrincipal | object = Depends(current_principal_dependency),
|
|
107
|
+
db: Any = Depends(get_db),
|
|
108
|
+
) -> MyAccountConsentOut:
|
|
109
|
+
try:
|
|
110
|
+
record = await account_self_service_for_request(
|
|
111
|
+
request or object(), db
|
|
112
|
+
).revoke_consent(account_principal(current_user), consent_id)
|
|
113
|
+
except AccountNotFoundError as exc:
|
|
114
|
+
raise HTTPException(404, str(exc)) from exc
|
|
115
|
+
return _consent_payload(record)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@router.route(
|
|
119
|
+
"/account/authorized-apps",
|
|
120
|
+
methods=["GET"],
|
|
121
|
+
response_model=list[MyAccountAuthorizedAppOut],
|
|
122
|
+
tags=MY_ACCOUNT_TAGS,
|
|
123
|
+
)
|
|
124
|
+
async def list_account_authorized_apps(
|
|
125
|
+
request: Request | None = None,
|
|
126
|
+
current_user: AccountPrincipal | object = Depends(current_principal_dependency),
|
|
127
|
+
db: Any = Depends(get_db),
|
|
128
|
+
) -> list[MyAccountAuthorizedAppOut]:
|
|
129
|
+
records = await account_self_service_for_request(
|
|
130
|
+
request or object(), db
|
|
131
|
+
).list_authorized_apps(account_principal(current_user))
|
|
132
|
+
return [_authorized_app_payload(record) for record in records]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@router.route(
|
|
136
|
+
"/account/authorized-apps/{client_id}",
|
|
137
|
+
methods=["DELETE"],
|
|
138
|
+
response_model=list[MyAccountConsentOut],
|
|
139
|
+
tags=MY_ACCOUNT_TAGS,
|
|
140
|
+
)
|
|
141
|
+
async def revoke_account_authorized_app(
|
|
142
|
+
client_id: str,
|
|
143
|
+
request: Request | None = None,
|
|
144
|
+
current_user: AccountPrincipal | object = Depends(current_principal_dependency),
|
|
145
|
+
db: Any = Depends(get_db),
|
|
146
|
+
) -> list[MyAccountConsentOut]:
|
|
147
|
+
try:
|
|
148
|
+
records = await account_self_service_for_request(
|
|
149
|
+
request or object(), db
|
|
150
|
+
).revoke_authorized_app(account_principal(current_user), client_id)
|
|
151
|
+
except AccountNotFoundError as exc:
|
|
152
|
+
raise HTTPException(404, str(exc)) from exc
|
|
153
|
+
return [_consent_payload(record) for record in records]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
__all__ = [
|
|
157
|
+
"MyAccountAuthorizedAppOut",
|
|
158
|
+
"MyAccountConsentOut",
|
|
159
|
+
"list_account_authorized_apps",
|
|
160
|
+
"list_account_consents",
|
|
161
|
+
"revoke_account_authorized_app",
|
|
162
|
+
"revoke_account_consent",
|
|
163
|
+
"router",
|
|
164
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""My Account backend-app contract for TBA."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class MyAccountBackendAppContract:
|
|
10
|
+
"""Machine-readable boundary for the end-user account backend app."""
|
|
11
|
+
|
|
12
|
+
product_surface: str
|
|
13
|
+
intended_uix: str
|
|
14
|
+
baseline_capabilities: tuple[str, ...]
|
|
15
|
+
forbidden_route_prefixes: tuple[str, ...]
|
|
16
|
+
forbidden_exact_routes: tuple[str, ...]
|
|
17
|
+
consumed_packages: tuple[str, ...]
|
|
18
|
+
mounted_router_packages: tuple[str, ...]
|
|
19
|
+
app_local_route_groups: tuple[str, ...]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
MY_ACCOUNT_BACKEND_APP_CONTRACT = MyAccountBackendAppContract(
|
|
23
|
+
product_surface="my-account-app",
|
|
24
|
+
intended_uix="@tba/ui-my-account",
|
|
25
|
+
baseline_capabilities=(
|
|
26
|
+
"account-profile",
|
|
27
|
+
"account-sessions",
|
|
28
|
+
"account-consents",
|
|
29
|
+
"account-credentials",
|
|
30
|
+
"rest-only",
|
|
31
|
+
"openid-configuration",
|
|
32
|
+
"tenant-openid-configuration",
|
|
33
|
+
"jwks",
|
|
34
|
+
"tenant-jwks",
|
|
35
|
+
),
|
|
36
|
+
forbidden_route_prefixes=(
|
|
37
|
+
"/admin",
|
|
38
|
+
"/tenant",
|
|
39
|
+
"/client",
|
|
40
|
+
"/serviceidentity",
|
|
41
|
+
"/user",
|
|
42
|
+
"/credentialapikey",
|
|
43
|
+
"/credentialservicekey",
|
|
44
|
+
"/cryptokey",
|
|
45
|
+
"/cryptokeyversion",
|
|
46
|
+
"/principalkeybinding",
|
|
47
|
+
"/keyenvelope",
|
|
48
|
+
"/keyattestationevidence",
|
|
49
|
+
"/tokenrecord",
|
|
50
|
+
"/authsession",
|
|
51
|
+
"/auditevent",
|
|
52
|
+
),
|
|
53
|
+
forbidden_exact_routes=(
|
|
54
|
+
"/login",
|
|
55
|
+
"/authorize",
|
|
56
|
+
"/token",
|
|
57
|
+
"/register",
|
|
58
|
+
"/logout",
|
|
59
|
+
"/revoke",
|
|
60
|
+
"/userinfo",
|
|
61
|
+
"/introspect",
|
|
62
|
+
),
|
|
63
|
+
consumed_packages=(
|
|
64
|
+
"tba-auth",
|
|
65
|
+
"tba-server-identity",
|
|
66
|
+
"tba-runtime-identity",
|
|
67
|
+
"tba-concrete-identity-principals",
|
|
68
|
+
"tba-credentials",
|
|
69
|
+
"tba-runtime-authorization-policy",
|
|
70
|
+
"tba-protocol-oidc",
|
|
71
|
+
"tba-storage-core",
|
|
72
|
+
),
|
|
73
|
+
mounted_router_packages=("tba-router-oidc-discovery",),
|
|
74
|
+
app_local_route_groups=(
|
|
75
|
+
"profiles",
|
|
76
|
+
"sessions",
|
|
77
|
+
"consents",
|
|
78
|
+
"credentials",
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
__all__ = ["MY_ACCOUNT_BACKEND_APP_CONTRACT", "MyAccountBackendAppContract"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""OpenAPI affordances for the My Account backend app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def patch_my_account_openapi(app: object) -> object:
|
|
10
|
+
"""Patch generated OpenAPI metadata for the My Account surface."""
|
|
11
|
+
target = getattr(app, "app", app)
|
|
12
|
+
original_openapi: Callable[[], dict[str, Any]] = target.openapi
|
|
13
|
+
|
|
14
|
+
def my_account_openapi() -> dict[str, Any]:
|
|
15
|
+
cached = getattr(target, "openapi_schema", None)
|
|
16
|
+
if cached:
|
|
17
|
+
return cached
|
|
18
|
+
openapi = original_openapi()
|
|
19
|
+
openapi["title"] = "tba-app-backend-my-account"
|
|
20
|
+
for path, methods in openapi.get("paths", {}).items():
|
|
21
|
+
if path.startswith("/account/"):
|
|
22
|
+
for operation in methods.values():
|
|
23
|
+
operation["tags"] = ["My Account"]
|
|
24
|
+
setattr(target, "openapi_schema", openapi)
|
|
25
|
+
return openapi
|
|
26
|
+
|
|
27
|
+
target.openapi = my_account_openapi
|
|
28
|
+
if target is not app:
|
|
29
|
+
app.openapi = my_account_openapi
|
|
30
|
+
|
|
31
|
+
routes = getattr(target, "_routes", None)
|
|
32
|
+
if isinstance(routes, list):
|
|
33
|
+
from tigrbl_concrete._concrete._response import Response
|
|
34
|
+
|
|
35
|
+
def _openapi_handler(request: Any) -> Response:
|
|
36
|
+
return Response.json(my_account_openapi())
|
|
37
|
+
|
|
38
|
+
patched_routes = []
|
|
39
|
+
for route in routes:
|
|
40
|
+
path = str(
|
|
41
|
+
getattr(route, "path_template", None) or getattr(route, "path", "")
|
|
42
|
+
)
|
|
43
|
+
methods = set(getattr(route, "methods", ()) or ())
|
|
44
|
+
if path == "/openapi.json" and "GET" in methods:
|
|
45
|
+
patched_routes.append(replace(route, handler=_openapi_handler))
|
|
46
|
+
else:
|
|
47
|
+
patched_routes.append(route)
|
|
48
|
+
routes[:] = patched_routes
|
|
49
|
+
return app
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = ["patch_my_account_openapi"]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Current-subject profile and password HTTP routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, constr
|
|
8
|
+
from tigrbl import TigrblRouter
|
|
9
|
+
from tigrbl.requests import Request
|
|
10
|
+
from tigrbl.runtime.status import HTTPException
|
|
11
|
+
from tigrbl.security import Depends
|
|
12
|
+
from tba_contracts_identity.account_self_service import (
|
|
13
|
+
AccountAuthenticationError,
|
|
14
|
+
AccountPrincipal,
|
|
15
|
+
AccountProfile,
|
|
16
|
+
AccountProfileUpdate,
|
|
17
|
+
AccountValidationError,
|
|
18
|
+
)
|
|
19
|
+
from tba_server_identity.account_self_service import (
|
|
20
|
+
account_principal,
|
|
21
|
+
account_self_service_for_request,
|
|
22
|
+
get_db,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .common import MyAccountMutationOut, current_principal_dependency
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_email = constr(strip_whitespace=True, min_length=3, max_length=120)
|
|
29
|
+
_password = constr(min_length=8, max_length=256)
|
|
30
|
+
_username = constr(strip_whitespace=True, min_length=3, max_length=80)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MyAccountProfileOut(BaseModel):
|
|
34
|
+
id: str
|
|
35
|
+
tenant_id: str
|
|
36
|
+
username: str
|
|
37
|
+
email: str
|
|
38
|
+
is_active: bool = True
|
|
39
|
+
must_change_password: bool = False
|
|
40
|
+
roles: list[str] = Field(default_factory=list)
|
|
41
|
+
created_at: str | None = None
|
|
42
|
+
updated_at: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MyAccountProfileUpdateIn(BaseModel):
|
|
46
|
+
username: _username | None = None
|
|
47
|
+
email: _email | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MyAccountPasswordChangeIn(BaseModel):
|
|
51
|
+
current_password: _password
|
|
52
|
+
new_password: _password
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _profile_payload(profile: AccountProfile) -> MyAccountProfileOut:
|
|
56
|
+
return MyAccountProfileOut(
|
|
57
|
+
id=profile.identity_id,
|
|
58
|
+
tenant_id=profile.tenant_id,
|
|
59
|
+
username=profile.username,
|
|
60
|
+
email=profile.email,
|
|
61
|
+
is_active=profile.is_active,
|
|
62
|
+
must_change_password=profile.must_change_password,
|
|
63
|
+
roles=list(profile.roles),
|
|
64
|
+
created_at=profile.created_at,
|
|
65
|
+
updated_at=profile.updated_at,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _principal(
|
|
70
|
+
request: Request | None, current_user: object | None, db: object
|
|
71
|
+
) -> AccountPrincipal:
|
|
72
|
+
if current_user is not None:
|
|
73
|
+
return account_principal(current_user)
|
|
74
|
+
if request is None:
|
|
75
|
+
raise HTTPException(401, "authenticated account required")
|
|
76
|
+
return await current_principal_dependency(request, db=db)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _capability(request: Request | None, db: object):
|
|
80
|
+
return (
|
|
81
|
+
account_self_service_for_request(request, db)
|
|
82
|
+
if request
|
|
83
|
+
else account_self_service_for_request(object(), db)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _raise_http(exc: Exception) -> None:
|
|
88
|
+
if isinstance(exc, AccountAuthenticationError):
|
|
89
|
+
raise HTTPException(401, str(exc)) from exc
|
|
90
|
+
if isinstance(exc, AccountValidationError):
|
|
91
|
+
raise HTTPException(400, str(exc)) from exc
|
|
92
|
+
raise exc
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
router = TigrblRouter()
|
|
96
|
+
MY_ACCOUNT_TAGS = ["My Account"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@router.route(
|
|
100
|
+
"/account/profile",
|
|
101
|
+
methods=["GET"],
|
|
102
|
+
response_model=MyAccountProfileOut,
|
|
103
|
+
tags=MY_ACCOUNT_TAGS,
|
|
104
|
+
)
|
|
105
|
+
async def get_account_profile(
|
|
106
|
+
request: Request | None = None,
|
|
107
|
+
current_user: object | None = None,
|
|
108
|
+
db: Any = Depends(get_db),
|
|
109
|
+
) -> MyAccountProfileOut:
|
|
110
|
+
principal = await _principal(request, current_user, db)
|
|
111
|
+
try:
|
|
112
|
+
return _profile_payload(await _capability(request, db).get_profile(principal))
|
|
113
|
+
except (AccountAuthenticationError, AccountValidationError) as exc:
|
|
114
|
+
_raise_http(exc)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@router.route(
|
|
118
|
+
"/account/profile",
|
|
119
|
+
methods=["PATCH"],
|
|
120
|
+
response_model=MyAccountProfileOut,
|
|
121
|
+
tags=MY_ACCOUNT_TAGS,
|
|
122
|
+
)
|
|
123
|
+
async def update_account_profile(
|
|
124
|
+
request: Request | None = None,
|
|
125
|
+
payload: MyAccountProfileUpdateIn | None = None,
|
|
126
|
+
current_user: object | None = None,
|
|
127
|
+
db: Any = Depends(get_db),
|
|
128
|
+
) -> MyAccountProfileOut:
|
|
129
|
+
principal = await _principal(request, current_user, db)
|
|
130
|
+
if payload is None:
|
|
131
|
+
if request is None:
|
|
132
|
+
raise HTTPException(400, "request payload required")
|
|
133
|
+
payload = MyAccountProfileUpdateIn.model_validate(await request.json() or {})
|
|
134
|
+
try:
|
|
135
|
+
profile = await _capability(request, db).update_profile(
|
|
136
|
+
principal, AccountProfileUpdate(payload.username, payload.email)
|
|
137
|
+
)
|
|
138
|
+
return _profile_payload(profile)
|
|
139
|
+
except (AccountAuthenticationError, AccountValidationError) as exc:
|
|
140
|
+
_raise_http(exc)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@router.route(
|
|
144
|
+
"/account/password/change",
|
|
145
|
+
methods=["POST"],
|
|
146
|
+
response_model=MyAccountMutationOut,
|
|
147
|
+
tags=MY_ACCOUNT_TAGS,
|
|
148
|
+
)
|
|
149
|
+
async def change_account_password(
|
|
150
|
+
request: Request | None = None,
|
|
151
|
+
payload: MyAccountPasswordChangeIn | None = None,
|
|
152
|
+
current_user: object | None = None,
|
|
153
|
+
db: Any = Depends(get_db),
|
|
154
|
+
) -> MyAccountMutationOut:
|
|
155
|
+
principal = await _principal(request, current_user, db)
|
|
156
|
+
if payload is None:
|
|
157
|
+
if request is None:
|
|
158
|
+
raise HTTPException(400, "request payload required")
|
|
159
|
+
payload = MyAccountPasswordChangeIn.model_validate(await request.json() or {})
|
|
160
|
+
try:
|
|
161
|
+
result = await _capability(request, db).change_password(
|
|
162
|
+
principal, payload.current_password, payload.new_password
|
|
163
|
+
)
|
|
164
|
+
return MyAccountMutationOut(status=result.status, id=result.resource_id)
|
|
165
|
+
except (AccountAuthenticationError, AccountValidationError) as exc:
|
|
166
|
+
_raise_http(exc)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
__all__ = [
|
|
170
|
+
"MyAccountPasswordChangeIn",
|
|
171
|
+
"MyAccountProfileOut",
|
|
172
|
+
"MyAccountProfileUpdateIn",
|
|
173
|
+
"change_account_password",
|
|
174
|
+
"get_account_profile",
|
|
175
|
+
"router",
|
|
176
|
+
"update_account_profile",
|
|
177
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""HTTP route composition for the My Account API product."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tigrbl import TigrblRouter
|
|
6
|
+
|
|
7
|
+
from .consents import router as consent_router
|
|
8
|
+
from .profiles import router as profile_router
|
|
9
|
+
from .sessions import router as session_router
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
router = TigrblRouter()
|
|
13
|
+
router.include_router(profile_router)
|
|
14
|
+
router.include_router(session_router)
|
|
15
|
+
router.include_router(consent_router)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = ["router"]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Current-subject authentication-session HTTP routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from tigrbl import TigrblRouter
|
|
9
|
+
from tigrbl.requests import Request
|
|
10
|
+
from tigrbl.runtime.status import HTTPException
|
|
11
|
+
from tigrbl.security import Depends
|
|
12
|
+
from tba_contracts_identity.account_self_service import (
|
|
13
|
+
AccountNotFoundError,
|
|
14
|
+
AccountPrincipal,
|
|
15
|
+
AccountSession,
|
|
16
|
+
)
|
|
17
|
+
from tba_server_identity.account_self_service import (
|
|
18
|
+
account_principal,
|
|
19
|
+
account_self_service_for_request,
|
|
20
|
+
get_db,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from .common import MyAccountMutationOut, current_principal_dependency
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MyAccountSessionOut(BaseModel):
|
|
27
|
+
id: str
|
|
28
|
+
tenant_id: str
|
|
29
|
+
user_id: str
|
|
30
|
+
username: str
|
|
31
|
+
client_id: str | None = None
|
|
32
|
+
state: str = "active"
|
|
33
|
+
auth_time: str | None = None
|
|
34
|
+
last_seen_at: str | None = None
|
|
35
|
+
expires_at: str | None = None
|
|
36
|
+
ended_at: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _session_payload(session: AccountSession) -> MyAccountSessionOut:
|
|
40
|
+
return MyAccountSessionOut(
|
|
41
|
+
id=session.session_id,
|
|
42
|
+
tenant_id=session.tenant_id,
|
|
43
|
+
user_id=session.identity_id,
|
|
44
|
+
username=session.username,
|
|
45
|
+
client_id=session.client_id,
|
|
46
|
+
state=session.state,
|
|
47
|
+
auth_time=session.auth_time,
|
|
48
|
+
last_seen_at=session.last_seen_at,
|
|
49
|
+
expires_at=session.expires_at,
|
|
50
|
+
ended_at=session.ended_at,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
router = TigrblRouter()
|
|
55
|
+
MY_ACCOUNT_TAGS = ["My Account"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.route(
|
|
59
|
+
"/account/sessions",
|
|
60
|
+
methods=["GET"],
|
|
61
|
+
response_model=list[MyAccountSessionOut],
|
|
62
|
+
tags=MY_ACCOUNT_TAGS,
|
|
63
|
+
)
|
|
64
|
+
async def list_account_sessions(
|
|
65
|
+
request: Request | None = None,
|
|
66
|
+
current_user: AccountPrincipal | object = Depends(current_principal_dependency),
|
|
67
|
+
db: Any = Depends(get_db),
|
|
68
|
+
) -> list[MyAccountSessionOut]:
|
|
69
|
+
principal = account_principal(current_user)
|
|
70
|
+
sessions = await account_self_service_for_request(
|
|
71
|
+
request or object(), db
|
|
72
|
+
).list_sessions(principal)
|
|
73
|
+
return [_session_payload(session) for session in sessions]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.route(
|
|
77
|
+
"/account/sessions/{session_id}",
|
|
78
|
+
methods=["DELETE"],
|
|
79
|
+
response_model=MyAccountMutationOut,
|
|
80
|
+
tags=MY_ACCOUNT_TAGS,
|
|
81
|
+
)
|
|
82
|
+
async def revoke_account_session(
|
|
83
|
+
session_id: str,
|
|
84
|
+
request: Request | None = None,
|
|
85
|
+
current_user: AccountPrincipal | object = Depends(current_principal_dependency),
|
|
86
|
+
db: Any = Depends(get_db),
|
|
87
|
+
) -> MyAccountMutationOut:
|
|
88
|
+
try:
|
|
89
|
+
result = await account_self_service_for_request(
|
|
90
|
+
request or object(), db
|
|
91
|
+
).revoke_session(account_principal(current_user), session_id)
|
|
92
|
+
except AccountNotFoundError as exc:
|
|
93
|
+
raise HTTPException(404, str(exc)) from exc
|
|
94
|
+
return MyAccountMutationOut(status=result.status, id=result.resource_id)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
__all__ = [
|
|
98
|
+
"MyAccountSessionOut",
|
|
99
|
+
"list_account_sessions",
|
|
100
|
+
"revoke_account_session",
|
|
101
|
+
"router",
|
|
102
|
+
]
|