connected_app.py 3.3 KB

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