Skip to content

Commit 57cf36e

Browse files
rboni-dkaarthy-dk
authored andcommitted
feat(oauth): personal access token schema and model
1 parent 0fb9eef commit 57cf36e

15 files changed

Lines changed: 241 additions & 144 deletions

File tree

testgen/api/oauth/models.py

Lines changed: 0 additions & 45 deletions
This file was deleted.

testgen/api/oauth/routes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@
2121
from testgen import settings
2222
from testgen.api.deps import db_session
2323
from testgen.api.oauth.login import render_login_page
24-
from testgen.api.oauth.models import OAuth2Client
2524
from testgen.api.oauth.server import TestGenAuthorizationServer
2625
from testgen.common.auth import create_jwt_token, decode_jwt_token, verify_password
2726
from testgen.common.models import get_current_session
27+
from testgen.common.models.oauth import OAuth2Client
2828
from testgen.common.models.user import User
2929

3030
LOG = logging.getLogger("testgen")

testgen/api/oauth/server.py

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
33
Grant types:
44
- Authorization Code + PKCE (for MCP clients)
5-
- Client Credentials (for automation scripts)
65
- Refresh Token (for token renewal)
76
87
All DB operations use get_current_session() for thread-local session access.
@@ -13,15 +12,14 @@
1312
from typing import ClassVar
1413

1514
from authlib.oauth2.rfc6749 import AuthorizationServer, JsonRequest, OAuth2Request, grants
16-
from authlib.oauth2.rfc6749.errors import InvalidGrantError
1715
from authlib.oauth2.rfc7009 import RevocationEndpoint
1816
from authlib.oauth2.rfc7636 import CodeChallenge
1917
from sqlalchemy import select
2018

2119
from testgen import settings
22-
from testgen.api.oauth.models import OAuth2AuthorizationCode, OAuth2Client, OAuth2Token
2320
from testgen.common.auth import create_jwt_token
2421
from testgen.common.models import get_current_session
22+
from testgen.common.models.oauth import OAuth2AuthorizationCode, OAuth2Client, OAuth2Token
2523
from testgen.common.models.user import User
2624

2725

@@ -85,24 +83,6 @@ def revoke_old_credential(self, credential):
8583
credential.access_token_revoked_at = int(time.time())
8684

8785

88-
class ClientCredentialsGrant(grants.ClientCredentialsGrant):
89-
"""Client credentials grant that resolves the client's owner as the token user.
90-
91-
Ensures every token has a real User identity — no "ghost" usernames.
92-
"""
93-
94-
def validate_token_request(self):
95-
super().validate_token_request()
96-
client = self.request.client
97-
if not client.user_id:
98-
raise InvalidGrantError(description="Client has no registered owner.")
99-
session = get_current_session()
100-
owner = session.scalars(select(User).where(User.id == client.user_id)).first()
101-
if owner is None:
102-
raise InvalidGrantError(description="Client owner no longer exists.")
103-
self.request.user = owner
104-
105-
10686
class TestGenRevocationEndpoint(RevocationEndpoint):
10787
def query_token(self, token_string, token_type_hint):
10888
session = get_current_session()
@@ -183,7 +163,6 @@ def create_authorization_server() -> TestGenAuthorizationServer:
183163
"""Create and configure the authorization server with all grant types."""
184164
server = TestGenAuthorizationServer()
185165
server.register_grant(AuthorizationCodeGrant, [CodeChallenge(required=True)])
186-
server.register_grant(ClientCredentialsGrant)
187166
server.register_grant(RefreshTokenGrant)
188167
server.register_endpoint(TestGenRevocationEndpoint)
189168
server.register_token_generator("default", _generate_bearer_token)

testgen/common/auth.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44

55
import bcrypt
66
import jwt
7+
from sqlalchemy import func, select
78

89
from testgen import settings
10+
from testgen.common.models.oauth import OAuth2Token
11+
from testgen.common.models.user import User
912

1013
LOG = logging.getLogger("testgen")
1114

@@ -45,11 +48,6 @@ def authorize_token(token_str: str, username: str, session):
4548
4649
Shared implementation for API and MCP authorization.
4750
"""
48-
from sqlalchemy import func, select
49-
50-
from testgen.api.oauth.models import OAuth2Token
51-
from testgen.common.models.user import User
52-
5351
user = session.scalars(select(User).where(func.lower(User.username) == func.lower(username))).first()
5452
if user is None:
5553
raise AuthError("User not found")

testgen/common/models/oauth.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import time
2+
from enum import StrEnum
3+
4+
from authlib.integrations.sqla_oauth2 import (
5+
OAuth2AuthorizationCodeMixin,
6+
OAuth2ClientMixin,
7+
OAuth2TokenMixin,
8+
)
9+
from sqlalchemy import Column, ForeignKey, String, case, func
10+
from sqlalchemy.dialects import postgresql
11+
from sqlalchemy.ext.hybrid import hybrid_property
12+
13+
from testgen import settings
14+
from testgen.common.models import Base
15+
16+
17+
class OAuth2ClientType(StrEnum):
18+
"""How an OAuth2 client was provisioned.
19+
20+
PAT clients are created by the authenticated personal-access-token path and owned by
21+
a user; EXTERNAL clients are dynamically registered (MCP apps, automation scripts).
22+
"""
23+
24+
PAT = "pat"
25+
EXTERNAL = "external"
26+
27+
28+
class PersonalAccessTokenStatus(StrEnum):
29+
"""Display status of a personal access token. Revoked takes precedence over expired."""
30+
31+
ACTIVE = "active"
32+
REVOKED = "revoked"
33+
EXPIRED = "expired"
34+
35+
36+
class OAuth2Client(Base, OAuth2ClientMixin):
37+
__tablename__ = "oauth2_clients"
38+
39+
id = Column(postgresql.UUID(as_uuid=True), primary_key=True, server_default="gen_random_uuid()")
40+
client_type = Column(String(20), nullable=False, default=OAuth2ClientType.EXTERNAL)
41+
42+
# Override to widen — JWTs can exceed 255 chars
43+
# (the mixin defines client_id as VARCHAR(48) which is fine)
44+
45+
46+
class OAuth2AuthorizationCode(Base, OAuth2AuthorizationCodeMixin):
47+
__tablename__ = "oauth2_authorization_codes"
48+
49+
id = Column(postgresql.UUID(as_uuid=True), primary_key=True, server_default="gen_random_uuid()")
50+
user_id = Column(postgresql.UUID(as_uuid=True), ForeignKey("auth_users.id", ondelete="CASCADE"), nullable=False)
51+
52+
53+
class OAuth2Token(Base, OAuth2TokenMixin):
54+
__tablename__ = "oauth2_tokens"
55+
56+
id = Column(postgresql.UUID(as_uuid=True), primary_key=True, server_default="gen_random_uuid()")
57+
user_id = Column(postgresql.UUID(as_uuid=True), ForeignKey("auth_users.id", ondelete="CASCADE"), nullable=True)
58+
59+
# Override to allow longer JWTs as access tokens
60+
access_token = Column(String(2048), unique=True, nullable=False)
61+
62+
# Set only for personal access tokens; NULL for tokens from the auth-code / MCP flows
63+
name = Column(String(255), nullable=True)
64+
65+
def is_refresh_token_active(self) -> bool:
66+
if self.refresh_token_revoked_at:
67+
return False
68+
expires_at = self.issued_at + settings.REFRESH_TOKEN_EXPIRES_IN
69+
return expires_at >= time.time()
70+
71+
@hybrid_property
72+
def status(self) -> PersonalAccessTokenStatus:
73+
"""Personal access token status, derived from revocation and expiry.
74+
75+
Instance side reads the app clock; the SQL expression reads the DB clock —
76+
both are wall-clock "now", evaluated independently.
77+
"""
78+
if self.access_token_revoked_at:
79+
return PersonalAccessTokenStatus.REVOKED
80+
if self.issued_at + self.expires_in < time.time():
81+
return PersonalAccessTokenStatus.EXPIRED
82+
return PersonalAccessTokenStatus.ACTIVE
83+
84+
@status.expression # type: ignore[no-redef]
85+
def status(cls):
86+
return case(
87+
(cls.access_token_revoked_at > 0, PersonalAccessTokenStatus.REVOKED.value),
88+
(cls.issued_at + cls.expires_in < func.extract("epoch", func.now()), PersonalAccessTokenStatus.EXPIRED.value),
89+
else_=PersonalAccessTokenStatus.ACTIVE.value,
90+
)

testgen/settings.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,17 @@ def _ssl_files_present() -> bool:
533533
Lifetime of OAuth access and refresh tokens.
534534
"""
535535

536+
PAT_DEFAULT_LIFETIME_SECONDS: int = 31_536_000 # 365 days
537+
"""
538+
Default maximum lifetime a personal access token can be created with.
539+
"""
540+
541+
PAT_MAX_LIFETIME_SECONDS: int = 63_072_000 # 2 years (730 days)
542+
"""
543+
Absolute ceiling on the admin-configurable personal access token maximum lifetime.
544+
The configured maximum cannot exceed this.
545+
"""
546+
536547
JWT_HASHING_KEY_B64: str = getenv("TG_JWT_HASHING_KEY")
537548
"""
538549
Random key used to sign/verify the authentication token

testgen/template/dbsetup/030_initialize_new_schema_structure.sql

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -742,12 +742,12 @@ CREATE INDEX ix_pm_role ON project_memberships(role);
742742

743743
CREATE TABLE oauth2_clients (
744744
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
745-
user_id UUID REFERENCES auth_users(id) ON DELETE SET NULL,
746745
client_id VARCHAR(48) NOT NULL UNIQUE,
747746
client_secret VARCHAR(120),
748747
client_id_issued_at INTEGER NOT NULL DEFAULT 0,
749748
client_secret_expires_at INTEGER NOT NULL DEFAULT 0,
750-
client_metadata TEXT NOT NULL DEFAULT '{}'
749+
client_metadata TEXT NOT NULL DEFAULT '{}',
750+
client_type VARCHAR(20) NOT NULL DEFAULT 'external'
751751
);
752752
CREATE INDEX idx_oauth2_clients_client_id ON oauth2_clients(client_id);
753753

@@ -778,7 +778,8 @@ CREATE TABLE oauth2_tokens (
778778
issued_at INTEGER NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::INTEGER,
779779
access_token_revoked_at INTEGER NOT NULL DEFAULT 0,
780780
refresh_token_revoked_at INTEGER NOT NULL DEFAULT 0,
781-
expires_in INTEGER NOT NULL DEFAULT 0
781+
expires_in INTEGER NOT NULL DEFAULT 0,
782+
name VARCHAR(255)
782783
);
783784
CREATE INDEX idx_oauth2_tokens_refresh_token ON oauth2_tokens(refresh_token);
784785

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
SET SEARCH_PATH TO {SCHEMA_NAME};
2+
3+
-- Personal access tokens are stored as oauth2_tokens rows; the name labels each
4+
-- one in the user's token list. NULL marks a non-PAT token (auth-code / MCP flows).
5+
ALTER TABLE oauth2_tokens ADD COLUMN name VARCHAR(255);
6+
7+
-- Classify clients so personal-access-token clients are distinguishable from
8+
-- dynamically-registered external clients (MCP apps, automation). Existing rows
9+
-- all predate PATs and are externally registered, so they default to 'external'.
10+
ALTER TABLE oauth2_clients ADD COLUMN client_type VARCHAR(20) NOT NULL DEFAULT 'external';
11+
12+
-- Drop the client owner: it was only ever read by the client-credentials grant
13+
-- (removed). User identity now rides the token (oauth2_tokens.user_id + the JWT
14+
-- username), so a client needs no owner. The FK constraint drops with the column.
15+
ALTER TABLE oauth2_clients DROP COLUMN user_id;

testgen/ui/app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def render(log_level: int = logging.INFO):
8484
support_email=settings.SUPPORT_EMAIL,
8585
global_context=is_global_context,
8686
is_global_admin=session.auth.user_has_permission("global_admin") and bool(application.global_admin_paths),
87+
account_path=application.account_path,
8788
)
8889

8990
application.router.run()

testgen/ui/bootstrap.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,14 @@
4949

5050

5151
class Application(singleton.Singleton):
52-
def __init__(self, auth_class: Authentication, logo: plugins.Logo, router: Router, menu: Menu, logger: logging.Logger, global_admin_paths: frozenset[str]) -> None:
52+
def __init__(self, auth_class: Authentication, logo: plugins.Logo, router: Router, menu: Menu, logger: logging.Logger, global_admin_paths: frozenset[str], account_path: str | None) -> None:
5353
self.auth_class = auth_class
5454
self.logo = logo
5555
self.router = router
5656
self.menu = menu
5757
self.logger = logger
5858
self.global_admin_paths = global_admin_paths
59+
self.account_path = account_path
5960

6061

6162
def run(log_level: int = logging.INFO) -> Application:
@@ -91,4 +92,5 @@ def run(log_level: int = logging.INFO) -> Application:
9192
),
9293
logger=LOG,
9394
global_admin_paths=frozenset(page.path for page in pages if page.permission == "global_admin"),
95+
account_path=next((page.path for page in pages if page.is_account_page), None),
9496
)

0 commit comments

Comments
 (0)