connected_app.py 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  1. from datetime import datetime
  2. from typing import Literal
  3. from urllib.parse import urlsplit
  4. from pydantic import BaseModel, Field, field_validator
  5. from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
  6. from backend.app.schemas.print_queue import UTCDatetime
  7. # RFC 7636: 43-128 chars from the unreserved set. The challenge is the
  8. # base64url SHA-256 of the verifier, so it is always exactly 43 chars.
  9. _PKCE_VERIFIER_PATTERN = r"^[A-Za-z0-9\-._~]{43,128}$"
  10. _PKCE_CHALLENGE_PATTERN = r"^[A-Za-z0-9\-_]{43}$"
  11. def _validate_redirect_uri(value: str) -> str:
  12. """Accept only an absolute http(s) URL without a fragment.
  13. Plain http is allowed: connected apps typically run on the same LAN as
  14. Bambuddy, often without TLS. What matters is that codes can only ever be
  15. delivered to the one URL an admin registered, and that is enforced by an
  16. exact match at authorize and token time.
  17. """
  18. value = value.strip()
  19. parts = urlsplit(value)
  20. if parts.scheme not in ("http", "https") or not parts.netloc:
  21. raise ValueError("Callback URL must be an absolute http:// or https:// URL")
  22. if parts.fragment:
  23. raise ValueError("Callback URL must not contain a #fragment")
  24. if parts.username or parts.password:
  25. raise ValueError("Callback URL must not contain credentials")
  26. return value
  27. class ConnectedAppCreate(BaseModel):
  28. name: str = Field(min_length=1, max_length=100)
  29. redirect_uri: str = Field(min_length=1, max_length=500)
  30. _check_redirect = field_validator("redirect_uri")(_validate_redirect_uri)
  31. class ConnectedAppUpdate(IgnoresNullForRequiredColumns):
  32. __orm_table__ = "connected_apps"
  33. name: str | None = Field(default=None, min_length=1, max_length=100)
  34. redirect_uri: str | None = Field(default=None, min_length=1, max_length=500)
  35. enabled: bool | None = None
  36. @field_validator("redirect_uri")
  37. @classmethod
  38. def _check_redirect(cls, value: str | None) -> str | None:
  39. return None if value is None else _validate_redirect_uri(value)
  40. class ConnectedAppResponse(BaseModel):
  41. id: int
  42. name: str
  43. client_id: str
  44. redirect_uri: str
  45. enabled: bool
  46. created_at: UTCDatetime
  47. last_used_at: UTCDatetime | None = None
  48. class Config:
  49. from_attributes = True
  50. class ConnectedAppSecretResponse(ConnectedAppResponse):
  51. """Returned by create and rotate only: the one time the secret is shown."""
  52. client_secret: str
  53. class ConnectAuthorizeInfo(BaseModel):
  54. app_name: str
  55. username: str
  56. already_granted: bool
  57. class ConnectAuthorizeRequest(BaseModel):
  58. client_id: str = Field(min_length=1, max_length=64)
  59. redirect_uri: str = Field(min_length=1, max_length=500)
  60. code_challenge: str = Field(pattern=_PKCE_CHALLENGE_PATTERN)
  61. code_challenge_method: Literal["S256"]
  62. class ConnectAuthorizeResponse(BaseModel):
  63. code: str
  64. redirect_uri: str
  65. class ConnectTokenRequest(BaseModel):
  66. grant_type: Literal["authorization_code"]
  67. code: str = Field(min_length=1, max_length=128)
  68. redirect_uri: str = Field(min_length=1, max_length=500)
  69. client_id: str = Field(min_length=1, max_length=64)
  70. client_secret: str = Field(min_length=1, max_length=128)
  71. code_verifier: str = Field(pattern=_PKCE_VERIFIER_PATTERN)
  72. class ConnectedUser(BaseModel):
  73. id: int
  74. username: str
  75. email: str | None = None
  76. is_admin: bool
  77. groups: list[str]
  78. permissions: list[str]
  79. class ConnectTokenResponse(BaseModel):
  80. user: ConnectedUser
  81. issued_at: datetime