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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions docs/docs/concept/auto_authn_v2_overview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# auto_authn.v2 Overview

## Router Endpoint Modules
- `rfc7591.py` – client registration endpoint
- `rfc9126.py` – pushed authorization request endpoint
- `rfc8628.py` – device authorization endpoint
- `rfc8932.py` – enhanced authorization server metadata endpoint
- `rfc8414.py` – OAuth 2.0 authorization server metadata endpoint
- `rfc7009.py` – token revocation endpoint
- `rfc8693.py` – token exchange endpoint
- `oidc_userinfo.py` – OpenID Connect `/userinfo` endpoint
- `oidc_discovery.py` – OpenID discovery endpoints
- `routers/auth_flows.py` – aggregates authentication and authorization routers
- `routers/authn/__init__.py` – authentication routes (register/login/logout)
- `routers/authz/__init__.py` – authorization routes (token, introspection, etc.)
- `routers/crud.py` – AutoAPI-generated CRUD router for ORM models

## Schema Modules
- `routers/schemas.py` – request/response models for auth flows
- `rfc7591.py` – `ClientMetadata` for dynamic client registration
- `rfc8628.py` – `DeviceAuthIn`, `DeviceAuthOut`, and `DeviceGrantForm`
- `rfc9396.py` – `AuthorizationDetail` for rich authorization requests
- `rfc8693.py` – `TokenType`, `TokenExchangeRequest`, and `TokenExchangeResponse`
- `orm/` modules – SQLAlchemy models (`Tenant`, `User`, `Client`, `Service`, `ApiKey`, `ServiceKey`, `AuthSession`, `AuthCode`, `DeviceCode`, `RevokedToken`, `PushedAuthorizationRequest`)

### Persistence vs. Virtual Schemas
- **Persistent**: ORM models from `orm/` (listed above)
- **Virtual**: Pydantic/in-memory classes such as `RegisterIn`, `TokenPair`, `ClientMetadata`, `DeviceAuthIn`, `DeviceAuthOut`, `DeviceGrantForm`, `AuthorizationDetail`, `TokenType`, `TokenExchangeRequest`, and `TokenExchangeResponse`

## Crypto Modules
- `crypto.py` – bcrypt password hashing and Ed25519 key management
- `rfc7515.py` – JSON Web Signature helpers
- `rfc7516.py` – JSON Web Encryption helpers
- `rfc7517.py` – loading signing and public JWKs
- `rfc7518.py` – supported JOSE algorithms list
- `rfc7519.py` – JWT encode/decode wrappers
- `rfc7638.py` – JWK thumbprint generation and verification
- `rfc7800.py` – confirmation claim and proof-of-possession utilities
- `rfc8291.py` – AES-128-GCM encryption/decryption for push messages
- `rfc8037.py` – EdDSA signing and verification helpers
- `rfc8705.py` – certificate thumbprint and binding validation
- `rfc9449_dpop.py` – DPoP proof creation and verification

## ORM Tables, Columns, and Operations
| Table | Acols (stored columns) | Vcols (virtual/relationships) | Default Ops | Additional Ops | Hook Context |
|-------|------------------------|-------------------------------|-------------|----------------|--------------|
| `Tenant` | `id`, `slug`, `created_at`, `updated_at`, `name`, `email` | — | create, read, update, delete, list | — | — |
| `User` | `id`, `created_at`, `updated_at`, `tenant_id`, `username`, `email`, `password_hash`, `is_active` | `api_keys` relationship | create, read, update, delete, list | register, login | `hash_pw` pre-create/pre-update for password hashing |
| `Client` | `id`, `created_at`, `updated_at`, `tenant_id`, `client_secret_hash`, `redirect_uris`, `is_active` | — | create, read, update, delete, list | dynamic client registration (`rfc7591`) | optional `hash_client_secret` hook |
| `Service` | `id`, `created_at`, `updated_at`, `tenant_id`, `is_active`, `name` | `service_keys` relationship | create, read, update, delete, list | — | `encrypt_service_key` if needed |
| `ApiKey` | `id`, `created_at`, `last_used_at`, `valid_from`, `valid_to`, `label`, `digest`, `user_id` | `user` relationship | create, read, update, delete, list | generate/return raw key | pre-create `generate_api_key`, post-create `return_raw_key` |
| `ServiceKey` | same as `ApiKey` plus `service_id` | `service` relationship | create, read, update, delete, list | — | similar hooks as `ApiKey` |
| `AuthSession` | `id`, `user_id`, `tenant_id`, `username`, `auth_time`, `created_at`, `updated_at` | — | create, read, update, delete, list | — | — |
| `AuthCode` | `code`, `user_id`, `tenant_id`, `client_id`, `redirect_uri`, `code_challenge`, `nonce`, `scope`, `expires_at`, `claims`, `created_at`, `updated_at` | — | create, read, update, delete, list | — | — |
| `DeviceCode` | `device_code`, `user_code`, `client_id`, `expires_at`, `interval`, `authorized`, `user_id`, `tenant_id`, `created_at`, `updated_at` | — | create, read, update, delete, list | device authorization | `issue_device_code`, `notify_user_agent` hooks when persisted |
| `RevokedToken` | `token`, `created_at`, `updated_at` | — | create, read, update, delete, list | revoke | `store_revoked_token` pre-create |
| `PushedAuthorizationRequest` | `request_uri`, `params`, `expires_at`, `created_at`, `updated_at` | — | create, read, update, delete, list | pushed authorization request | `persist_par_request` pre-create |

### Notes on Operations
- **op_alias**: no explicit overrides; CRUD uses default verbs.
- **schema_ctx**: use when virtual fields (e.g., `password`) cannot map directly to persistent columns.
- **Lifecycle hooks**: attach callables like `_pwd_backend.verify`, `_jwt.encode`, or custom crypto/providers to appropriate `pre_*` or `post_*` phases.

## Hook Context Examples
| Endpoint | Lifecycle Hook | Purpose |
|----------|----------------|---------|
| `POST /register` | `pre_create` → `hash_pw` | Hash incoming password before persisting `User` record |
| `POST /login` | `pre_read` → `_pwd_backend.verify` | Verify password before issuing tokens |
| CRUD `ApiKey` | `pre_create` → `generate_api_key`, `post_create` → `return_raw_key` | Create digest and return plaintext key |
| `POST /token` | `pre_read`/`post_create` → `_pwd_backend.verify`, `_jwt.encode` | Validate client secrets and sign issued tokens |
| `POST /revoke` | `pre_create` → `store_revoked_token` | Persist revoked tokens to `RevokedToken` table |
| `POST /device_authorization` | `pre_create`/`post_create` → `issue_device_code`, `notify_user_agent` | Generate and optionally persist device/user codes |
| `POST /par` | `pre_create` → `persist_par_request` | Store pushed authorization request |
| `POST /token/exchange` | `post_create` → `_jwt.encode` | Sign exchanged tokens |
| `GET /userinfo` | `post_read` → `_jwt.encode` (optional) | Optionally sign the userinfo response |

46 changes: 41 additions & 5 deletions pkgs/standards/auto_authn/auto_authn/v2/orm/api_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@

from __future__ import annotations

from autoapi.v2.tables import ApiKey as ApiKeyBase
from autoapi.v2.types import UniqueConstraint, relationship
from autoapi.v2.mixins import UserMixin
import hashlib
import secrets

from autoapi.v3.tables import ApiKey as ApiKeyBase
from autoapi.v3.types import UniqueConstraint, relationship
from autoapi.v3.mixins import UserMixin
from autoapi.v3.specs import IO, vcol
from autoapi.v3 import hook_ctx
from typing import TYPE_CHECKING

if TYPE_CHECKING: # pragma: no cover
from .user import User


class ApiKey(ApiKeyBase, UserMixin):
Expand All @@ -13,11 +22,38 @@ class ApiKey(ApiKeyBase, UserMixin):
{"extend_existing": True, "schema": "authn"},
)

user = relationship(
_user = relationship(
"auto_authn.v2.orm.tables.User",
back_populates="api_keys",
back_populates="_api_keys",
lazy="joined", # optional: eager load to avoid N+1
)

user: "User" = vcol(
read_producer=lambda obj, _ctx: obj._user,
io=IO(out_verbs=("read", "list")),
)

@hook_ctx(ops="create", phase="PRE_HANDLER")
async def _generate_digest(cls, ctx):
payload = ctx.get("payload") or {}
token = secrets.token_urlsafe(32)
payload["digest"] = hashlib.sha256(token.encode()).hexdigest()
ctx["raw_key"] = token

@hook_ctx(ops="create", phase="POST_RESPONSE")
async def _return_raw_key(cls, ctx):
raw = ctx.get("raw_key")
result = ctx.get("result")
if not raw or result is None:
return
if hasattr(result, "model_dump"):
data = result.model_dump()
elif hasattr(result, "dict") and callable(result.dict):
data = result.dict() # type: ignore[call-arg]
else:
data = dict(result)
data["raw_key"] = raw
ctx["result"] = data


__all__ = ["ApiKey"]
54 changes: 27 additions & 27 deletions pkgs/standards/auto_authn/auto_authn/v2/orm/auth_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,39 @@

from __future__ import annotations

from autoapi.v2 import Base
from autoapi.v2.mixins import Timestamped
from autoapi.v2.types import Column, ForeignKey, JSON, PgUUID, String, TZDateTime
import datetime as dt
import uuid

from autoapi.v3.tables import Base
from autoapi.v3.mixins import TenantMixin, Timestamped, UserMixin
from autoapi.v3.specs import S, acol
from autoapi.v3.specs.storage_spec import ForeignKeySpec
from autoapi.v3.types import JSON, PgUUID, String, TZDateTime
from autoapi.v3 import op_ctx

class AuthCode(Base, Timestamped):

class AuthCode(Base, Timestamped, UserMixin, TenantMixin):
__tablename__ = "auth_codes"
__table_args__ = ({"schema": "authn"},)

code = Column(String(128), primary_key=True)
user_id = Column(
PgUUID(as_uuid=True),
ForeignKey("authn.users.id"),
nullable=False,
index=True,
)
tenant_id = Column(
PgUUID(as_uuid=True),
ForeignKey("authn.tenants.id"),
nullable=False,
index=True,
code: str = acol(storage=S(String(128), primary_key=True))
client_id: uuid.UUID = acol(
storage=S(
PgUUID(as_uuid=True),
fk=ForeignKeySpec(target="authn.clients.id"),
nullable=False,
)
)
client_id = Column(
PgUUID(as_uuid=True),
ForeignKey("authn.clients.id"),
nullable=False,
)
redirect_uri = Column(String(1000), nullable=False)
code_challenge = Column(String, nullable=True)
nonce = Column(String, nullable=True)
scope = Column(String, nullable=True)
expires_at = Column(TZDateTime, nullable=False)
claims = Column(JSON, nullable=True)
redirect_uri: str = acol(storage=S(String(1000), nullable=False))
code_challenge: str | None = acol(storage=S(String, nullable=True))
nonce: str | None = acol(storage=S(String, nullable=True))
scope: str | None = acol(storage=S(String, nullable=True))
expires_at: dt.datetime = acol(storage=S(TZDateTime, nullable=False))
claims: dict | None = acol(storage=S(JSON, nullable=True))

@op_ctx(alias="exchange", target="delete", arity="member")
def exchange(cls, ctx, obj):
return obj


__all__ = ["AuthCode"]
64 changes: 44 additions & 20 deletions pkgs/standards/auto_authn/auto_authn/v2/orm/auth_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,56 @@

import datetime as dt

from autoapi.v2 import Base
from autoapi.v2.mixins import Timestamped
from autoapi.v2.types import Column, ForeignKey, PgUUID, String, TZDateTime
from autoapi.v3.tables import Base
from autoapi.v3.mixins import TenantMixin, Timestamped, UserMixin
from autoapi.v3.specs import S, acol
from autoapi.v3.types import String, TZDateTime
from autoapi.v3 import op_ctx, hook_ctx
from fastapi import HTTPException


class AuthSession(Base, Timestamped):
class AuthSession(Base, Timestamped, UserMixin, TenantMixin):
__tablename__ = "sessions"
__table_args__ = ({"schema": "authn"},)

id = Column(String(64), primary_key=True)
user_id = Column(
PgUUID(as_uuid=True),
ForeignKey("authn.users.id"),
nullable=False,
index=True,
)
tenant_id = Column(
PgUUID(as_uuid=True),
ForeignKey("authn.tenants.id"),
nullable=False,
index=True,
)
username = Column(String(120), nullable=False)
auth_time = Column(
TZDateTime, default=lambda: dt.datetime.now(dt.timezone.utc), nullable=False
id: str = acol(storage=S(String(64), primary_key=True))
username: str = acol(storage=S(String(120), nullable=False))
auth_time: dt.datetime = acol(
storage=S(
TZDateTime, nullable=False, default=lambda: dt.datetime.now(dt.timezone.utc)
)
)

@hook_ctx(ops="login", phase="PRE_HANDLER")
async def _verify_credentials(cls, ctx):
from .user import User

payload = ctx.get("payload") or {}
db = ctx.get("db")
username = payload.get("username")
password = payload.get("password")
if db is None or not username or not password:
raise HTTPException(status_code=400, detail="missing credentials")

users = await User.handlers.list.core(
{"db": db, "payload": {"filters": {"username": username}}}
)
user = users.items[0] if getattr(users, "items", None) else None
if user is None or not user.verify_password(password):
raise HTTPException(status_code=400, detail="invalid credentials")

payload.pop("password", None)
payload["user_id"] = user.id
payload["tenant_id"] = user.tenant_id
payload["username"] = user.username

@op_ctx(alias="login", target="create", arity="collection")
def login(cls, ctx):
pass

@op_ctx(alias="logout", target="delete", arity="member")
def logout(cls, ctx, obj):
return obj


__all__ = ["AuthSession"]
10 changes: 9 additions & 1 deletion pkgs/standards/auto_authn/auto_authn/v2/orm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
import uuid
from typing import Final

from autoapi.v2.tables import Client as ClientBase
from autoapi.v3.tables import Client as ClientBase
from autoapi.v3 import hook_ctx

from ..crypto import hash_pw
from ..rfc8252 import validate_native_redirect_uri
Expand All @@ -18,6 +19,13 @@
class Client(ClientBase):
__table_args__ = ({"schema": "authn"},)

@hook_ctx(ops=("create", "update"), phase="PRE_HANDLER")
async def _hash_secret(cls, ctx):
payload = ctx.get("payload") or {}
secret = payload.pop("client_secret", None)
if secret:
payload["client_secret_hash"] = hash_pw(secret)

@classmethod
def new(
cls,
Expand Down
68 changes: 38 additions & 30 deletions pkgs/standards/auto_authn/auto_authn/v2/orm/device_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,53 @@

from __future__ import annotations

from autoapi.v2 import Base
from autoapi.v2.mixins import Timestamped
from autoapi.v2.types import (
Boolean,
Column,
ForeignKey,
Integer,
PgUUID,
String,
TZDateTime,
)
import datetime as dt
import uuid

from autoapi.v3.tables import Base
from autoapi.v3.mixins import Timestamped
from autoapi.v3.specs import S, acol
from autoapi.v3.specs.storage_spec import ForeignKeySpec
from autoapi.v3.types import Boolean, Integer, PgUUID, String, TZDateTime
from autoapi.v3 import op_ctx


class DeviceCode(Base, Timestamped):
__tablename__ = "device_codes"
__table_args__ = ({"schema": "authn"},)

device_code = Column(String(128), primary_key=True)
user_code = Column(String(32), nullable=False, index=True)
client_id = Column(
PgUUID(as_uuid=True),
ForeignKey("authn.clients.id"),
nullable=False,
device_code: str = acol(storage=S(String(128), primary_key=True))
user_code: str = acol(storage=S(String(32), nullable=False, index=True))
client_id: uuid.UUID = acol(
storage=S(
PgUUID(as_uuid=True),
fk=ForeignKeySpec(target="authn.clients.id"),
nullable=False,
)
)
expires_at = Column(TZDateTime, nullable=False)
interval = Column(Integer, nullable=False)
authorized = Column(Boolean, default=False, nullable=False)
user_id = Column(
PgUUID(as_uuid=True),
ForeignKey("authn.users.id"),
nullable=True,
index=True,
expires_at: dt.datetime = acol(storage=S(TZDateTime, nullable=False))
interval: int = acol(storage=S(Integer, nullable=False))
authorized: bool = acol(storage=S(Boolean, nullable=False, default=False))
user_id: uuid.UUID | None = acol(
storage=S(
PgUUID(as_uuid=True),
fk=ForeignKeySpec(target="authn.users.id"),
nullable=True,
index=True,
)
)
tenant_id = Column(
PgUUID(as_uuid=True),
ForeignKey("authn.tenants.id"),
nullable=True,
index=True,
tenant_id: uuid.UUID | None = acol(
storage=S(
PgUUID(as_uuid=True),
fk=ForeignKeySpec(target="authn.tenants.id"),
nullable=True,
index=True,
)
)

@op_ctx(alias="device_authorization", target="create", arity="collection")
def device_authorization(cls, ctx):
pass


__all__ = ["DeviceCode"]
Loading
Loading