ctfy.sdk.resources.auth
client.auth — password auth, OAuth identities, API tokens, discovery.
1"""``client.auth`` — password auth, OAuth identities, API tokens, discovery.""" 2 3from __future__ import annotations 4 5from urllib.parse import quote 6 7from ctfy.sdk._helpers import _raise_for_status 8from ctfy.sdk.base import BaseHttpClient 9from ctfy.server.models import ( 10 AffiliationSelection, 11 AuthTokenResponse, 12 CreateFineGrainedTokenResponse, 13 DeviceCodeResponse, 14 DeviceTokenResponse, 15 EmailVerificationSent, 16 EmailVerificationStatus, 17 LinkedIdentity, 18 LinkStartResponse, 19 ProvidersResponse, 20 TokenInfo, 21 TokenScopesResponse, 22 TokenTemplatesResponse, 23 UserEmailInfo, 24) 25 26 27class AuthResource: 28 """Register / login, identity binding, token CRUD, sign-in discovery.""" 29 30 def __init__(self, http: BaseHttpClient) -> None: 31 self._http = http 32 33 def register( 34 self, 35 email: str, 36 password: str, 37 display_name: str = "", 38 ) -> AuthTokenResponse: 39 """Register via email + password. Returns a fresh user-kind token. 40 41 First-user bootstrap: when the backend has zero users, the new 42 user is granted ``role="super_admin"``. 43 """ 44 resp = self._http.request( 45 "POST", 46 "/auth/register", 47 json={"email": email, "password": password, "display_name": display_name}, 48 ) 49 _raise_for_status(resp) 50 return AuthTokenResponse.model_validate(resp.json()) 51 52 def login(self, email: str, password: str) -> AuthTokenResponse: 53 """Sign in with email + password. Returns a fresh user-kind token.""" 54 resp = self._http.request( 55 "POST", "/auth/login", json={"email": email, "password": password} 56 ) 57 _raise_for_status(resp) 58 return AuthTokenResponse.model_validate(resp.json()) 59 60 def device_code(self) -> DeviceCodeResponse: 61 """Start a device-authorization flow (unauthenticated, RFC 8628). 62 63 Returns a short ``user_code`` + a ``verification_uri`` the user 64 opens in a browser, plus the opaque ``device_code`` to poll with. 65 """ 66 resp = self._http.request("POST", "/auth/device/code") 67 _raise_for_status(resp) 68 return DeviceCodeResponse.model_validate(resp.json()) 69 70 def device_token(self, device_code: str) -> DeviceTokenResponse: 71 """Poll for the token after the user approves (unauthenticated). 72 73 ``status`` is ``pending`` / ``approved`` / ``denied`` / 74 ``expired``; ``token`` is set only when ``approved``. 75 """ 76 resp = self._http.request("POST", "/auth/device/token", json={"device_code": device_code}) 77 _raise_for_status(resp) 78 return DeviceTokenResponse.model_validate(resp.json()) 79 80 def set_password(self, new_password: str, current_password: str = "") -> None: 81 """Set or change the caller's password. 82 83 ``current_password`` is required iff the caller already has a 84 password identity; absent on the first-time "attach password to 85 OAuth-only account" flow. 86 """ 87 resp = self._http.request( 88 "POST", 89 "/auth/password", 90 json={"new_password": new_password, "current_password": current_password}, 91 ) 92 _raise_for_status(resp) 93 94 def delete_password(self) -> None: 95 """Drop the caller's password identity. Requires ≥1 OAuth identity.""" 96 resp = self._http.request("DELETE", "/auth/password") 97 _raise_for_status(resp) 98 99 def list_identities(self) -> list[LinkedIdentity]: 100 """Bound OAuth + password identities for the calling user.""" 101 resp = self._http.request("GET", "/auth/identities") 102 _raise_for_status(resp) 103 return [LinkedIdentity.model_validate(i) for i in resp.json()["items"]] 104 105 def unbind_identity(self, identity_id: str) -> None: 106 """Unbind one OAuth identity. Server blocks unlinking the last one.""" 107 resp = self._http.request("DELETE", f"/auth/identities/{identity_id}") 108 _raise_for_status(resp) 109 110 def start_oauth_link(self, provider: str) -> LinkStartResponse: 111 """Begin a "link a second OAuth identity to my account" flow. 112 113 Returns the platform's signed-state authorize URL — the actual 114 flow finishes in a browser (the user opens the URL and approves 115 linking the provider to their existing account). 116 """ 117 resp = self._http.request("POST", f"/auth/identities/link/{provider}") 118 _raise_for_status(resp) 119 return LinkStartResponse.model_validate(resp.json()) 120 121 def list_tokens(self, offset: int = 0, limit: int = 50) -> list[TokenInfo]: 122 """List caller's tokens (user + agent). Plaintext never returned.""" 123 resp = self._http.request("GET", "/auth/tokens", params={"offset": offset, "limit": limit}) 124 _raise_for_status(resp) 125 return [TokenInfo.model_validate(t) for t in resp.json()["items"]] 126 127 def token_templates(self) -> TokenTemplatesResponse: 128 """The named starting points ``mint_token(template=…)`` accepts. 129 130 Carries each template's competition access, its dense 131 permission map and its suggested lifetime — the mechanical 132 content, which is all a non-browser caller needs. Human copy 133 lives in the web client's locale bundles. 134 """ 135 resp = self._http.request("GET", "/auth/tokens/templates") 136 _raise_for_status(resp) 137 return TokenTemplatesResponse.model_validate(resp.json()) 138 139 def mint_token( 140 self, 141 label: str, 142 expires_in_days: int | None = None, 143 *, 144 template: str | None = None, 145 competition_access: str | None = None, 146 competition_ids: list[str] | None = None, 147 permissions: dict[str, str] | None = None, 148 ) -> CreateFineGrainedTokenResponse: 149 """Mint a fresh fine-grained token (``pf_*``). Plaintext ONCE. 150 151 With no scope args the server mints the broad legacy profile 152 (every competition, full participate access) so existing 153 callers keep working unchanged. To scope it GitHub-style pass 154 ``competition_access`` (``all`` / ``selected`` / ``none``), 155 ``competition_ids`` (when ``selected``) and ``permissions`` 156 (category → ``none`` / ``read`` / ``write``). 157 158 ``template`` names a preset (see :meth:`token_templates`) and is 159 a *starting point*: anything else passed alongside it wins, so 160 taking ``agent_play`` and narrowing it to two competitions is 161 one call. An unknown name is refused rather than defaulted — 162 the fall-back would be the broad profile, i.e. a wider token 163 than was asked for. 164 165 ``expires_in_days`` defaults server-side when ``None``. 166 """ 167 body: dict[str, object] = {"label": label} 168 if expires_in_days is not None: 169 body["expires_in_days"] = expires_in_days 170 if template is not None: 171 body["template"] = template 172 if competition_access is not None: 173 body["competition_access"] = competition_access 174 if competition_ids is not None: 175 body["competition_ids"] = competition_ids 176 if permissions is not None: 177 body["permissions"] = permissions 178 resp = self._http.request("POST", "/auth/tokens", json=body) 179 _raise_for_status(resp) 180 return CreateFineGrainedTokenResponse.model_validate(resp.json()) 181 182 def update_token_scopes( 183 self, 184 token_id: str, 185 *, 186 label: str | None = None, 187 template: str | None = None, 188 competition_access: str | None = None, 189 competition_ids: list[str] | None = None, 190 permissions: dict[str, str] | None = None, 191 ) -> TokenInfo: 192 """Edit a live token's label, competition reach and permissions. 193 194 **Partial**: only what you pass moves. Anything left ``None`` is 195 not sent at all, so editing permissions cannot blank the 196 competition scope by omission. 197 198 ``competition_ids`` has two absences that differ: ``None`` 199 leaves the list alone, ``[]`` clears it. 200 201 The secret, the expiry and the kind are unreachable from here — 202 :meth:`rotate_token` and :meth:`renew_token` are those, each 203 with its own refusals. A change that actually widens the token 204 mails its owner, in the same unswitchable ``security`` 205 category as the mint. 206 """ 207 body: dict[str, object] = {} 208 if label is not None: 209 body["label"] = label 210 if template is not None: 211 body["template"] = template 212 if competition_access is not None: 213 body["competition_access"] = competition_access 214 if competition_ids is not None: 215 body["competition_ids"] = competition_ids 216 if permissions is not None: 217 body["permissions"] = permissions 218 resp = self._http.request("PATCH", f"/auth/tokens/{token_id}", json=body) 219 _raise_for_status(resp) 220 return TokenInfo.model_validate(resp.json()) 221 222 def renew_token(self, token_id: str, expires_in_days: int | None = None) -> TokenInfo: 223 """Push a token's expiry out. The secret does not change. 224 225 Nothing to redeploy — which is the whole difference from 226 :meth:`rotate_token`. The new lifetime is measured from now, 227 never added to what is left; ``0`` means never expires and 228 ``None`` takes the server default. 229 230 Refused on a token that has **already** lapsed: expiry is the 231 one thing that kills a leaked credential nobody noticed, so 232 reviving a dead secret is the rotation's job. 233 """ 234 body: dict[str, object] = {} 235 if expires_in_days is not None: 236 body["expires_in_days"] = expires_in_days 237 resp = self._http.request("POST", f"/auth/tokens/{token_id}/renew", json=body) 238 _raise_for_status(resp) 239 return TokenInfo.model_validate(resp.json()) 240 241 def rotate_token( 242 self, token_id: str, expires_in_days: int | None = None 243 ) -> CreateFineGrainedTokenResponse: 244 """Replace a token's secret, carrying its scope across. 245 246 The answer to a leak and to a token that has already expired. 247 The plaintext comes back exactly once, the predecessor is 248 revoked in the same operation, and the reply's ``id`` is a new 249 one — a rotation is two linked rows, so an audit can still 250 resolve the old secret's hash. 251 """ 252 body: dict[str, object] = {} 253 if expires_in_days is not None: 254 body["expires_in_days"] = expires_in_days 255 resp = self._http.request("POST", f"/auth/tokens/{token_id}/rotate", json=body) 256 _raise_for_status(resp) 257 return CreateFineGrainedTokenResponse.model_validate(resp.json()) 258 259 def revoke_token(self, token_id: str) -> None: 260 """Revoke one token by id.""" 261 resp = self._http.request("DELETE", f"/auth/tokens/{token_id}") 262 _raise_for_status(resp) 263 264 def revoke_other_tokens(self) -> None: 265 """Revoke every token belonging to this user except the caller's own.""" 266 resp = self._http.request("DELETE", "/auth/tokens/others") 267 _raise_for_status(resp) 268 269 def providers(self) -> ProvidersResponse: 270 """Which sign-in methods this deployment offers (OAuth providers 271 configured + whether password auth is enabled).""" 272 resp = self._http.request("GET", "/auth/providers") 273 _raise_for_status(resp) 274 return ProvidersResponse.model_validate(resp.json()) 275 276 def token_scopes(self) -> TokenScopesResponse: 277 """The catalog of grantable fine-grained token scopes — drives 278 the token-create wizard.""" 279 resp = self._http.request("GET", "/auth/tokens/scopes") 280 _raise_for_status(resp) 281 return TokenScopesResponse.model_validate(resp.json()) 282 283 def request_email_code(self, email: str) -> dict[str, object]: 284 """Send a one-time sign-in code to ``email``. 285 286 Always reports success for a well-formed address whether or not 287 an account exists — the endpoint is deliberately not an account 288 enumeration oracle, and verification creates the account if 289 needed. 290 """ 291 resp = self._http.request("POST", "/auth/email-code", json={"email": email}) 292 _raise_for_status(resp) 293 return dict(resp.json()) 294 295 def verify_email_code(self, email: str, code: str) -> AuthTokenResponse: 296 """Exchange a sign-in code for a session token. 297 298 Creates the account on first use, which also verifies the address 299 by construction — unlike password registration, which never did. 300 """ 301 resp = self._http.request( 302 "POST", "/auth/email-code/verify", json={"email": email, "code": code} 303 ) 304 _raise_for_status(resp) 305 return AuthTokenResponse.model_validate(resp.json()) 306 307 def request_email_verification(self) -> EmailVerificationSent: 308 """Mail a verification code to the calling account's own address. 309 310 ⚠️ **Takes no address.** The code goes to whatever is already on 311 the account, which is what stops this being a way to have the 312 platform mail arbitrary inboxes and what stops a caller 313 attaching somebody else's address to their own account as 314 verified. 315 316 Wrapped rather than left browser-only because a deployment that 317 sets ``email_verification_required`` refuses *every* write from 318 an unproved account — including from a ``user``-kind token minted 319 by ``ctfy login``, which is exactly the credential this client 320 holds. Without these two methods a CLI user on such a deployment 321 would have no way forward that did not involve a browser. 322 """ 323 resp = self._http.request("POST", "/auth/email-verification") 324 _raise_for_status(resp) 325 return EmailVerificationSent.model_validate(resp.json()) 326 327 def email_verification_status(self) -> EmailVerificationStatus: 328 """Where the account stands, including whether a code is outstanding. 329 330 ``code_sent_at`` / ``code_expires_at`` are ``None`` when no 331 redeemable code exists (never sent, the send failed, expired). 332 """ 333 resp = self._http.request("GET", "/auth/email-verification") 334 _raise_for_status(resp) 335 return EmailVerificationStatus.model_validate(resp.json()) 336 337 def list_emails(self) -> list[UserEmailInfo]: 338 """Every address on the calling account, oldest claim first. 339 340 ``users.email`` is the *primary* — the address the platform mails 341 and the one ``email_verification_required`` rules on — and these 342 rows are the set it is chosen from, each carrying its own proof. 343 """ 344 resp = self._http.request("GET", "/me/emails") 345 _raise_for_status(resp) 346 return [UserEmailInfo.model_validate(e) for e in resp.json()["items"]] 347 348 def add_email(self, email: str) -> EmailVerificationSent: 349 """Mail a code to an address you want to attach to this account. 350 351 ⚠️ **Unlike :meth:`request_email_verification`, this one does take 352 an address, and the difference is where the code goes.** That one 353 mails whatever is already on the account, so a parameter would 354 make it a way to mail arbitrary inboxes on demand. Here the code 355 goes *to the address you name*, so the exchange proves nothing 356 unless you can read that mailbox — naming it is the act. 357 358 ⚠️ **Read ``handover`` on the answer before redeeming the code.** 359 An address another account already holds is not refused here; the 360 exchange *moves* it. ``"orphans"`` means that account has no 361 other way in and will be unreachable afterwards, which 362 :meth:`verify_added_email` refuses unless you say 363 ``acknowledge_orphan=True``. 364 """ 365 resp = self._http.request("POST", "/me/emails", json={"email": email}) 366 _raise_for_status(resp) 367 return EmailVerificationSent.model_validate(resp.json()) 368 369 def verify_added_email( 370 self, email: str, code: str, *, acknowledge_orphan: bool = False 371 ) -> UserEmailInfo: 372 """Exchange the code for a proved address on this account. 373 374 Carries the address as well as the code because the code table is 375 keyed by ``(address, purpose)`` — asking which address this code 376 is for *is* the lookup, not a convenience. 377 378 ⚠️ **``acknowledge_orphan`` defaults to refusing**, and the empty 379 case has to: a caller that never heard of handovers must not be 380 able to leave another account unreachable by omission. It is 381 needed only when :meth:`add_email` answered ``"orphans"`` — every 382 other move is free, and the losing account is mailed either way. 383 """ 384 resp = self._http.request( 385 "POST", 386 "/me/emails/verify", 387 json={"email": email, "code": code, "acknowledge_orphan": acknowledge_orphan}, 388 ) 389 _raise_for_status(resp) 390 return UserEmailInfo.model_validate(resp.json()) 391 392 def set_primary_email(self, email: str) -> UserEmailInfo: 393 """Point the account's primary address at one of its proved ones. 394 395 The verification stamp moves with the address; only a proved 396 address may be chosen, because the primary is where the platform 397 writes and what the verification gate rules on. 398 """ 399 resp = self._http.request("POST", f"/me/emails/{quote(email, safe='')}/primary") 400 _raise_for_status(resp) 401 return UserEmailInfo.model_validate(resp.json()) 402 403 def remove_email(self, email: str) -> None: 404 """Drop a secondary address. The primary is refused — make another 405 one primary first.""" 406 resp = self._http.request("DELETE", f"/me/emails/{quote(email, safe='')}") 407 _raise_for_status(resp) 408 409 def publish_affiliation(self, email: str) -> AffiliationSelection: 410 """Show an institution badge derived from one proved address. 411 412 Independent of :meth:`set_primary_email` — two choices over one 413 set of addresses, the mailbox the platform writes to and the one 414 a stranger's badge is derived from. Only a *proved* address is 415 accepted (409 otherwise): the badge is a published claim about a 416 person, so the platform shows only what it checked. 417 418 Nothing about the institution is stored. It is resolved on every 419 read, so the badge disappears by itself when the address is 420 removed, loses its proof, is handed to another account, or the 421 operator deletes the catalogue row. 422 """ 423 resp = self._http.request("POST", f"/me/emails/{quote(email, safe='')}/affiliation") 424 _raise_for_status(resp) 425 return AffiliationSelection.model_validate(resp.json()) 426 427 def clear_affiliation(self) -> AffiliationSelection: 428 """Stop showing a badge. 429 430 Takes no address, and is therefore never refused: a version 431 addressed by address would refuse to stop publishing a badge 432 derived from an address that has already been removed. 433 """ 434 resp = self._http.request("DELETE", "/me/affiliation") 435 _raise_for_status(resp) 436 return AffiliationSelection.model_validate(resp.json()) 437 438 def submit_email_verification(self, code: str) -> EmailVerificationStatus: 439 """Exchange the code for the verified stamp. 440 441 Every rejection is the same ``invalid_code``, deliberately: 442 separating "wrong" from "expired" from "already used" tells an 443 attacker which guesses were structurally close and tells a 444 legitimate caller nothing beyond "request a new one". 445 """ 446 resp = self._http.request("POST", "/auth/email-verification/verify", json={"code": code}) 447 _raise_for_status(resp) 448 return EmailVerificationStatus.model_validate(resp.json())
28class AuthResource: 29 """Register / login, identity binding, token CRUD, sign-in discovery.""" 30 31 def __init__(self, http: BaseHttpClient) -> None: 32 self._http = http 33 34 def register( 35 self, 36 email: str, 37 password: str, 38 display_name: str = "", 39 ) -> AuthTokenResponse: 40 """Register via email + password. Returns a fresh user-kind token. 41 42 First-user bootstrap: when the backend has zero users, the new 43 user is granted ``role="super_admin"``. 44 """ 45 resp = self._http.request( 46 "POST", 47 "/auth/register", 48 json={"email": email, "password": password, "display_name": display_name}, 49 ) 50 _raise_for_status(resp) 51 return AuthTokenResponse.model_validate(resp.json()) 52 53 def login(self, email: str, password: str) -> AuthTokenResponse: 54 """Sign in with email + password. Returns a fresh user-kind token.""" 55 resp = self._http.request( 56 "POST", "/auth/login", json={"email": email, "password": password} 57 ) 58 _raise_for_status(resp) 59 return AuthTokenResponse.model_validate(resp.json()) 60 61 def device_code(self) -> DeviceCodeResponse: 62 """Start a device-authorization flow (unauthenticated, RFC 8628). 63 64 Returns a short ``user_code`` + a ``verification_uri`` the user 65 opens in a browser, plus the opaque ``device_code`` to poll with. 66 """ 67 resp = self._http.request("POST", "/auth/device/code") 68 _raise_for_status(resp) 69 return DeviceCodeResponse.model_validate(resp.json()) 70 71 def device_token(self, device_code: str) -> DeviceTokenResponse: 72 """Poll for the token after the user approves (unauthenticated). 73 74 ``status`` is ``pending`` / ``approved`` / ``denied`` / 75 ``expired``; ``token`` is set only when ``approved``. 76 """ 77 resp = self._http.request("POST", "/auth/device/token", json={"device_code": device_code}) 78 _raise_for_status(resp) 79 return DeviceTokenResponse.model_validate(resp.json()) 80 81 def set_password(self, new_password: str, current_password: str = "") -> None: 82 """Set or change the caller's password. 83 84 ``current_password`` is required iff the caller already has a 85 password identity; absent on the first-time "attach password to 86 OAuth-only account" flow. 87 """ 88 resp = self._http.request( 89 "POST", 90 "/auth/password", 91 json={"new_password": new_password, "current_password": current_password}, 92 ) 93 _raise_for_status(resp) 94 95 def delete_password(self) -> None: 96 """Drop the caller's password identity. Requires ≥1 OAuth identity.""" 97 resp = self._http.request("DELETE", "/auth/password") 98 _raise_for_status(resp) 99 100 def list_identities(self) -> list[LinkedIdentity]: 101 """Bound OAuth + password identities for the calling user.""" 102 resp = self._http.request("GET", "/auth/identities") 103 _raise_for_status(resp) 104 return [LinkedIdentity.model_validate(i) for i in resp.json()["items"]] 105 106 def unbind_identity(self, identity_id: str) -> None: 107 """Unbind one OAuth identity. Server blocks unlinking the last one.""" 108 resp = self._http.request("DELETE", f"/auth/identities/{identity_id}") 109 _raise_for_status(resp) 110 111 def start_oauth_link(self, provider: str) -> LinkStartResponse: 112 """Begin a "link a second OAuth identity to my account" flow. 113 114 Returns the platform's signed-state authorize URL — the actual 115 flow finishes in a browser (the user opens the URL and approves 116 linking the provider to their existing account). 117 """ 118 resp = self._http.request("POST", f"/auth/identities/link/{provider}") 119 _raise_for_status(resp) 120 return LinkStartResponse.model_validate(resp.json()) 121 122 def list_tokens(self, offset: int = 0, limit: int = 50) -> list[TokenInfo]: 123 """List caller's tokens (user + agent). Plaintext never returned.""" 124 resp = self._http.request("GET", "/auth/tokens", params={"offset": offset, "limit": limit}) 125 _raise_for_status(resp) 126 return [TokenInfo.model_validate(t) for t in resp.json()["items"]] 127 128 def token_templates(self) -> TokenTemplatesResponse: 129 """The named starting points ``mint_token(template=…)`` accepts. 130 131 Carries each template's competition access, its dense 132 permission map and its suggested lifetime — the mechanical 133 content, which is all a non-browser caller needs. Human copy 134 lives in the web client's locale bundles. 135 """ 136 resp = self._http.request("GET", "/auth/tokens/templates") 137 _raise_for_status(resp) 138 return TokenTemplatesResponse.model_validate(resp.json()) 139 140 def mint_token( 141 self, 142 label: str, 143 expires_in_days: int | None = None, 144 *, 145 template: str | None = None, 146 competition_access: str | None = None, 147 competition_ids: list[str] | None = None, 148 permissions: dict[str, str] | None = None, 149 ) -> CreateFineGrainedTokenResponse: 150 """Mint a fresh fine-grained token (``pf_*``). Plaintext ONCE. 151 152 With no scope args the server mints the broad legacy profile 153 (every competition, full participate access) so existing 154 callers keep working unchanged. To scope it GitHub-style pass 155 ``competition_access`` (``all`` / ``selected`` / ``none``), 156 ``competition_ids`` (when ``selected``) and ``permissions`` 157 (category → ``none`` / ``read`` / ``write``). 158 159 ``template`` names a preset (see :meth:`token_templates`) and is 160 a *starting point*: anything else passed alongside it wins, so 161 taking ``agent_play`` and narrowing it to two competitions is 162 one call. An unknown name is refused rather than defaulted — 163 the fall-back would be the broad profile, i.e. a wider token 164 than was asked for. 165 166 ``expires_in_days`` defaults server-side when ``None``. 167 """ 168 body: dict[str, object] = {"label": label} 169 if expires_in_days is not None: 170 body["expires_in_days"] = expires_in_days 171 if template is not None: 172 body["template"] = template 173 if competition_access is not None: 174 body["competition_access"] = competition_access 175 if competition_ids is not None: 176 body["competition_ids"] = competition_ids 177 if permissions is not None: 178 body["permissions"] = permissions 179 resp = self._http.request("POST", "/auth/tokens", json=body) 180 _raise_for_status(resp) 181 return CreateFineGrainedTokenResponse.model_validate(resp.json()) 182 183 def update_token_scopes( 184 self, 185 token_id: str, 186 *, 187 label: str | None = None, 188 template: str | None = None, 189 competition_access: str | None = None, 190 competition_ids: list[str] | None = None, 191 permissions: dict[str, str] | None = None, 192 ) -> TokenInfo: 193 """Edit a live token's label, competition reach and permissions. 194 195 **Partial**: only what you pass moves. Anything left ``None`` is 196 not sent at all, so editing permissions cannot blank the 197 competition scope by omission. 198 199 ``competition_ids`` has two absences that differ: ``None`` 200 leaves the list alone, ``[]`` clears it. 201 202 The secret, the expiry and the kind are unreachable from here — 203 :meth:`rotate_token` and :meth:`renew_token` are those, each 204 with its own refusals. A change that actually widens the token 205 mails its owner, in the same unswitchable ``security`` 206 category as the mint. 207 """ 208 body: dict[str, object] = {} 209 if label is not None: 210 body["label"] = label 211 if template is not None: 212 body["template"] = template 213 if competition_access is not None: 214 body["competition_access"] = competition_access 215 if competition_ids is not None: 216 body["competition_ids"] = competition_ids 217 if permissions is not None: 218 body["permissions"] = permissions 219 resp = self._http.request("PATCH", f"/auth/tokens/{token_id}", json=body) 220 _raise_for_status(resp) 221 return TokenInfo.model_validate(resp.json()) 222 223 def renew_token(self, token_id: str, expires_in_days: int | None = None) -> TokenInfo: 224 """Push a token's expiry out. The secret does not change. 225 226 Nothing to redeploy — which is the whole difference from 227 :meth:`rotate_token`. The new lifetime is measured from now, 228 never added to what is left; ``0`` means never expires and 229 ``None`` takes the server default. 230 231 Refused on a token that has **already** lapsed: expiry is the 232 one thing that kills a leaked credential nobody noticed, so 233 reviving a dead secret is the rotation's job. 234 """ 235 body: dict[str, object] = {} 236 if expires_in_days is not None: 237 body["expires_in_days"] = expires_in_days 238 resp = self._http.request("POST", f"/auth/tokens/{token_id}/renew", json=body) 239 _raise_for_status(resp) 240 return TokenInfo.model_validate(resp.json()) 241 242 def rotate_token( 243 self, token_id: str, expires_in_days: int | None = None 244 ) -> CreateFineGrainedTokenResponse: 245 """Replace a token's secret, carrying its scope across. 246 247 The answer to a leak and to a token that has already expired. 248 The plaintext comes back exactly once, the predecessor is 249 revoked in the same operation, and the reply's ``id`` is a new 250 one — a rotation is two linked rows, so an audit can still 251 resolve the old secret's hash. 252 """ 253 body: dict[str, object] = {} 254 if expires_in_days is not None: 255 body["expires_in_days"] = expires_in_days 256 resp = self._http.request("POST", f"/auth/tokens/{token_id}/rotate", json=body) 257 _raise_for_status(resp) 258 return CreateFineGrainedTokenResponse.model_validate(resp.json()) 259 260 def revoke_token(self, token_id: str) -> None: 261 """Revoke one token by id.""" 262 resp = self._http.request("DELETE", f"/auth/tokens/{token_id}") 263 _raise_for_status(resp) 264 265 def revoke_other_tokens(self) -> None: 266 """Revoke every token belonging to this user except the caller's own.""" 267 resp = self._http.request("DELETE", "/auth/tokens/others") 268 _raise_for_status(resp) 269 270 def providers(self) -> ProvidersResponse: 271 """Which sign-in methods this deployment offers (OAuth providers 272 configured + whether password auth is enabled).""" 273 resp = self._http.request("GET", "/auth/providers") 274 _raise_for_status(resp) 275 return ProvidersResponse.model_validate(resp.json()) 276 277 def token_scopes(self) -> TokenScopesResponse: 278 """The catalog of grantable fine-grained token scopes — drives 279 the token-create wizard.""" 280 resp = self._http.request("GET", "/auth/tokens/scopes") 281 _raise_for_status(resp) 282 return TokenScopesResponse.model_validate(resp.json()) 283 284 def request_email_code(self, email: str) -> dict[str, object]: 285 """Send a one-time sign-in code to ``email``. 286 287 Always reports success for a well-formed address whether or not 288 an account exists — the endpoint is deliberately not an account 289 enumeration oracle, and verification creates the account if 290 needed. 291 """ 292 resp = self._http.request("POST", "/auth/email-code", json={"email": email}) 293 _raise_for_status(resp) 294 return dict(resp.json()) 295 296 def verify_email_code(self, email: str, code: str) -> AuthTokenResponse: 297 """Exchange a sign-in code for a session token. 298 299 Creates the account on first use, which also verifies the address 300 by construction — unlike password registration, which never did. 301 """ 302 resp = self._http.request( 303 "POST", "/auth/email-code/verify", json={"email": email, "code": code} 304 ) 305 _raise_for_status(resp) 306 return AuthTokenResponse.model_validate(resp.json()) 307 308 def request_email_verification(self) -> EmailVerificationSent: 309 """Mail a verification code to the calling account's own address. 310 311 ⚠️ **Takes no address.** The code goes to whatever is already on 312 the account, which is what stops this being a way to have the 313 platform mail arbitrary inboxes and what stops a caller 314 attaching somebody else's address to their own account as 315 verified. 316 317 Wrapped rather than left browser-only because a deployment that 318 sets ``email_verification_required`` refuses *every* write from 319 an unproved account — including from a ``user``-kind token minted 320 by ``ctfy login``, which is exactly the credential this client 321 holds. Without these two methods a CLI user on such a deployment 322 would have no way forward that did not involve a browser. 323 """ 324 resp = self._http.request("POST", "/auth/email-verification") 325 _raise_for_status(resp) 326 return EmailVerificationSent.model_validate(resp.json()) 327 328 def email_verification_status(self) -> EmailVerificationStatus: 329 """Where the account stands, including whether a code is outstanding. 330 331 ``code_sent_at`` / ``code_expires_at`` are ``None`` when no 332 redeemable code exists (never sent, the send failed, expired). 333 """ 334 resp = self._http.request("GET", "/auth/email-verification") 335 _raise_for_status(resp) 336 return EmailVerificationStatus.model_validate(resp.json()) 337 338 def list_emails(self) -> list[UserEmailInfo]: 339 """Every address on the calling account, oldest claim first. 340 341 ``users.email`` is the *primary* — the address the platform mails 342 and the one ``email_verification_required`` rules on — and these 343 rows are the set it is chosen from, each carrying its own proof. 344 """ 345 resp = self._http.request("GET", "/me/emails") 346 _raise_for_status(resp) 347 return [UserEmailInfo.model_validate(e) for e in resp.json()["items"]] 348 349 def add_email(self, email: str) -> EmailVerificationSent: 350 """Mail a code to an address you want to attach to this account. 351 352 ⚠️ **Unlike :meth:`request_email_verification`, this one does take 353 an address, and the difference is where the code goes.** That one 354 mails whatever is already on the account, so a parameter would 355 make it a way to mail arbitrary inboxes on demand. Here the code 356 goes *to the address you name*, so the exchange proves nothing 357 unless you can read that mailbox — naming it is the act. 358 359 ⚠️ **Read ``handover`` on the answer before redeeming the code.** 360 An address another account already holds is not refused here; the 361 exchange *moves* it. ``"orphans"`` means that account has no 362 other way in and will be unreachable afterwards, which 363 :meth:`verify_added_email` refuses unless you say 364 ``acknowledge_orphan=True``. 365 """ 366 resp = self._http.request("POST", "/me/emails", json={"email": email}) 367 _raise_for_status(resp) 368 return EmailVerificationSent.model_validate(resp.json()) 369 370 def verify_added_email( 371 self, email: str, code: str, *, acknowledge_orphan: bool = False 372 ) -> UserEmailInfo: 373 """Exchange the code for a proved address on this account. 374 375 Carries the address as well as the code because the code table is 376 keyed by ``(address, purpose)`` — asking which address this code 377 is for *is* the lookup, not a convenience. 378 379 ⚠️ **``acknowledge_orphan`` defaults to refusing**, and the empty 380 case has to: a caller that never heard of handovers must not be 381 able to leave another account unreachable by omission. It is 382 needed only when :meth:`add_email` answered ``"orphans"`` — every 383 other move is free, and the losing account is mailed either way. 384 """ 385 resp = self._http.request( 386 "POST", 387 "/me/emails/verify", 388 json={"email": email, "code": code, "acknowledge_orphan": acknowledge_orphan}, 389 ) 390 _raise_for_status(resp) 391 return UserEmailInfo.model_validate(resp.json()) 392 393 def set_primary_email(self, email: str) -> UserEmailInfo: 394 """Point the account's primary address at one of its proved ones. 395 396 The verification stamp moves with the address; only a proved 397 address may be chosen, because the primary is where the platform 398 writes and what the verification gate rules on. 399 """ 400 resp = self._http.request("POST", f"/me/emails/{quote(email, safe='')}/primary") 401 _raise_for_status(resp) 402 return UserEmailInfo.model_validate(resp.json()) 403 404 def remove_email(self, email: str) -> None: 405 """Drop a secondary address. The primary is refused — make another 406 one primary first.""" 407 resp = self._http.request("DELETE", f"/me/emails/{quote(email, safe='')}") 408 _raise_for_status(resp) 409 410 def publish_affiliation(self, email: str) -> AffiliationSelection: 411 """Show an institution badge derived from one proved address. 412 413 Independent of :meth:`set_primary_email` — two choices over one 414 set of addresses, the mailbox the platform writes to and the one 415 a stranger's badge is derived from. Only a *proved* address is 416 accepted (409 otherwise): the badge is a published claim about a 417 person, so the platform shows only what it checked. 418 419 Nothing about the institution is stored. It is resolved on every 420 read, so the badge disappears by itself when the address is 421 removed, loses its proof, is handed to another account, or the 422 operator deletes the catalogue row. 423 """ 424 resp = self._http.request("POST", f"/me/emails/{quote(email, safe='')}/affiliation") 425 _raise_for_status(resp) 426 return AffiliationSelection.model_validate(resp.json()) 427 428 def clear_affiliation(self) -> AffiliationSelection: 429 """Stop showing a badge. 430 431 Takes no address, and is therefore never refused: a version 432 addressed by address would refuse to stop publishing a badge 433 derived from an address that has already been removed. 434 """ 435 resp = self._http.request("DELETE", "/me/affiliation") 436 _raise_for_status(resp) 437 return AffiliationSelection.model_validate(resp.json()) 438 439 def submit_email_verification(self, code: str) -> EmailVerificationStatus: 440 """Exchange the code for the verified stamp. 441 442 Every rejection is the same ``invalid_code``, deliberately: 443 separating "wrong" from "expired" from "already used" tells an 444 attacker which guesses were structurally close and tells a 445 legitimate caller nothing beyond "request a new one". 446 """ 447 resp = self._http.request("POST", "/auth/email-verification/verify", json={"code": code}) 448 _raise_for_status(resp) 449 return EmailVerificationStatus.model_validate(resp.json())
Register / login, identity binding, token CRUD, sign-in discovery.
34 def register( 35 self, 36 email: str, 37 password: str, 38 display_name: str = "", 39 ) -> AuthTokenResponse: 40 """Register via email + password. Returns a fresh user-kind token. 41 42 First-user bootstrap: when the backend has zero users, the new 43 user is granted ``role="super_admin"``. 44 """ 45 resp = self._http.request( 46 "POST", 47 "/auth/register", 48 json={"email": email, "password": password, "display_name": display_name}, 49 ) 50 _raise_for_status(resp) 51 return AuthTokenResponse.model_validate(resp.json())
Register via email + password. Returns a fresh user-kind token.
First-user bootstrap: when the backend has zero users, the new
user is granted role="super_admin".
53 def login(self, email: str, password: str) -> AuthTokenResponse: 54 """Sign in with email + password. Returns a fresh user-kind token.""" 55 resp = self._http.request( 56 "POST", "/auth/login", json={"email": email, "password": password} 57 ) 58 _raise_for_status(resp) 59 return AuthTokenResponse.model_validate(resp.json())
Sign in with email + password. Returns a fresh user-kind token.
61 def device_code(self) -> DeviceCodeResponse: 62 """Start a device-authorization flow (unauthenticated, RFC 8628). 63 64 Returns a short ``user_code`` + a ``verification_uri`` the user 65 opens in a browser, plus the opaque ``device_code`` to poll with. 66 """ 67 resp = self._http.request("POST", "/auth/device/code") 68 _raise_for_status(resp) 69 return DeviceCodeResponse.model_validate(resp.json())
Start a device-authorization flow (unauthenticated, RFC 8628).
Returns a short user_code + a verification_uri the user
opens in a browser, plus the opaque device_code to poll with.
71 def device_token(self, device_code: str) -> DeviceTokenResponse: 72 """Poll for the token after the user approves (unauthenticated). 73 74 ``status`` is ``pending`` / ``approved`` / ``denied`` / 75 ``expired``; ``token`` is set only when ``approved``. 76 """ 77 resp = self._http.request("POST", "/auth/device/token", json={"device_code": device_code}) 78 _raise_for_status(resp) 79 return DeviceTokenResponse.model_validate(resp.json())
Poll for the token after the user approves (unauthenticated).
status is pending / approved / denied /
expired; token is set only when approved.
81 def set_password(self, new_password: str, current_password: str = "") -> None: 82 """Set or change the caller's password. 83 84 ``current_password`` is required iff the caller already has a 85 password identity; absent on the first-time "attach password to 86 OAuth-only account" flow. 87 """ 88 resp = self._http.request( 89 "POST", 90 "/auth/password", 91 json={"new_password": new_password, "current_password": current_password}, 92 ) 93 _raise_for_status(resp)
Set or change the caller's password.
current_password is required iff the caller already has a
password identity; absent on the first-time "attach password to
OAuth-only account" flow.
95 def delete_password(self) -> None: 96 """Drop the caller's password identity. Requires ≥1 OAuth identity.""" 97 resp = self._http.request("DELETE", "/auth/password") 98 _raise_for_status(resp)
Drop the caller's password identity. Requires ≥1 OAuth identity.
100 def list_identities(self) -> list[LinkedIdentity]: 101 """Bound OAuth + password identities for the calling user.""" 102 resp = self._http.request("GET", "/auth/identities") 103 _raise_for_status(resp) 104 return [LinkedIdentity.model_validate(i) for i in resp.json()["items"]]
Bound OAuth + password identities for the calling user.
106 def unbind_identity(self, identity_id: str) -> None: 107 """Unbind one OAuth identity. Server blocks unlinking the last one.""" 108 resp = self._http.request("DELETE", f"/auth/identities/{identity_id}") 109 _raise_for_status(resp)
Unbind one OAuth identity. Server blocks unlinking the last one.
111 def start_oauth_link(self, provider: str) -> LinkStartResponse: 112 """Begin a "link a second OAuth identity to my account" flow. 113 114 Returns the platform's signed-state authorize URL — the actual 115 flow finishes in a browser (the user opens the URL and approves 116 linking the provider to their existing account). 117 """ 118 resp = self._http.request("POST", f"/auth/identities/link/{provider}") 119 _raise_for_status(resp) 120 return LinkStartResponse.model_validate(resp.json())
Begin a "link a second OAuth identity to my account" flow.
Returns the platform's signed-state authorize URL — the actual flow finishes in a browser (the user opens the URL and approves linking the provider to their existing account).
122 def list_tokens(self, offset: int = 0, limit: int = 50) -> list[TokenInfo]: 123 """List caller's tokens (user + agent). Plaintext never returned.""" 124 resp = self._http.request("GET", "/auth/tokens", params={"offset": offset, "limit": limit}) 125 _raise_for_status(resp) 126 return [TokenInfo.model_validate(t) for t in resp.json()["items"]]
List caller's tokens (user + agent). Plaintext never returned.
128 def token_templates(self) -> TokenTemplatesResponse: 129 """The named starting points ``mint_token(template=…)`` accepts. 130 131 Carries each template's competition access, its dense 132 permission map and its suggested lifetime — the mechanical 133 content, which is all a non-browser caller needs. Human copy 134 lives in the web client's locale bundles. 135 """ 136 resp = self._http.request("GET", "/auth/tokens/templates") 137 _raise_for_status(resp) 138 return TokenTemplatesResponse.model_validate(resp.json())
The named starting points mint_token(template=…) accepts.
Carries each template's competition access, its dense permission map and its suggested lifetime — the mechanical content, which is all a non-browser caller needs. Human copy lives in the web client's locale bundles.
140 def mint_token( 141 self, 142 label: str, 143 expires_in_days: int | None = None, 144 *, 145 template: str | None = None, 146 competition_access: str | None = None, 147 competition_ids: list[str] | None = None, 148 permissions: dict[str, str] | None = None, 149 ) -> CreateFineGrainedTokenResponse: 150 """Mint a fresh fine-grained token (``pf_*``). Plaintext ONCE. 151 152 With no scope args the server mints the broad legacy profile 153 (every competition, full participate access) so existing 154 callers keep working unchanged. To scope it GitHub-style pass 155 ``competition_access`` (``all`` / ``selected`` / ``none``), 156 ``competition_ids`` (when ``selected``) and ``permissions`` 157 (category → ``none`` / ``read`` / ``write``). 158 159 ``template`` names a preset (see :meth:`token_templates`) and is 160 a *starting point*: anything else passed alongside it wins, so 161 taking ``agent_play`` and narrowing it to two competitions is 162 one call. An unknown name is refused rather than defaulted — 163 the fall-back would be the broad profile, i.e. a wider token 164 than was asked for. 165 166 ``expires_in_days`` defaults server-side when ``None``. 167 """ 168 body: dict[str, object] = {"label": label} 169 if expires_in_days is not None: 170 body["expires_in_days"] = expires_in_days 171 if template is not None: 172 body["template"] = template 173 if competition_access is not None: 174 body["competition_access"] = competition_access 175 if competition_ids is not None: 176 body["competition_ids"] = competition_ids 177 if permissions is not None: 178 body["permissions"] = permissions 179 resp = self._http.request("POST", "/auth/tokens", json=body) 180 _raise_for_status(resp) 181 return CreateFineGrainedTokenResponse.model_validate(resp.json())
Mint a fresh fine-grained token (pf_*). Plaintext ONCE.
With no scope args the server mints the broad legacy profile
(every competition, full participate access) so existing
callers keep working unchanged. To scope it GitHub-style pass
competition_access (all / selected / none),
competition_ids (when selected) and permissions
(category → none / read / write).
template names a preset (see token_templates()) and is
a starting point: anything else passed alongside it wins, so
taking agent_play and narrowing it to two competitions is
one call. An unknown name is refused rather than defaulted —
the fall-back would be the broad profile, i.e. a wider token
than was asked for.
expires_in_days defaults server-side when None.
183 def update_token_scopes( 184 self, 185 token_id: str, 186 *, 187 label: str | None = None, 188 template: str | None = None, 189 competition_access: str | None = None, 190 competition_ids: list[str] | None = None, 191 permissions: dict[str, str] | None = None, 192 ) -> TokenInfo: 193 """Edit a live token's label, competition reach and permissions. 194 195 **Partial**: only what you pass moves. Anything left ``None`` is 196 not sent at all, so editing permissions cannot blank the 197 competition scope by omission. 198 199 ``competition_ids`` has two absences that differ: ``None`` 200 leaves the list alone, ``[]`` clears it. 201 202 The secret, the expiry and the kind are unreachable from here — 203 :meth:`rotate_token` and :meth:`renew_token` are those, each 204 with its own refusals. A change that actually widens the token 205 mails its owner, in the same unswitchable ``security`` 206 category as the mint. 207 """ 208 body: dict[str, object] = {} 209 if label is not None: 210 body["label"] = label 211 if template is not None: 212 body["template"] = template 213 if competition_access is not None: 214 body["competition_access"] = competition_access 215 if competition_ids is not None: 216 body["competition_ids"] = competition_ids 217 if permissions is not None: 218 body["permissions"] = permissions 219 resp = self._http.request("PATCH", f"/auth/tokens/{token_id}", json=body) 220 _raise_for_status(resp) 221 return TokenInfo.model_validate(resp.json())
Edit a live token's label, competition reach and permissions.
Partial: only what you pass moves. Anything left None is
not sent at all, so editing permissions cannot blank the
competition scope by omission.
competition_ids has two absences that differ: None
leaves the list alone, [] clears it.
The secret, the expiry and the kind are unreachable from here —
rotate_token() and renew_token() are those, each
with its own refusals. A change that actually widens the token
mails its owner, in the same unswitchable security
category as the mint.
223 def renew_token(self, token_id: str, expires_in_days: int | None = None) -> TokenInfo: 224 """Push a token's expiry out. The secret does not change. 225 226 Nothing to redeploy — which is the whole difference from 227 :meth:`rotate_token`. The new lifetime is measured from now, 228 never added to what is left; ``0`` means never expires and 229 ``None`` takes the server default. 230 231 Refused on a token that has **already** lapsed: expiry is the 232 one thing that kills a leaked credential nobody noticed, so 233 reviving a dead secret is the rotation's job. 234 """ 235 body: dict[str, object] = {} 236 if expires_in_days is not None: 237 body["expires_in_days"] = expires_in_days 238 resp = self._http.request("POST", f"/auth/tokens/{token_id}/renew", json=body) 239 _raise_for_status(resp) 240 return TokenInfo.model_validate(resp.json())
Push a token's expiry out. The secret does not change.
Nothing to redeploy — which is the whole difference from
rotate_token(). The new lifetime is measured from now,
never added to what is left; 0 means never expires and
None takes the server default.
Refused on a token that has already lapsed: expiry is the one thing that kills a leaked credential nobody noticed, so reviving a dead secret is the rotation's job.
242 def rotate_token( 243 self, token_id: str, expires_in_days: int | None = None 244 ) -> CreateFineGrainedTokenResponse: 245 """Replace a token's secret, carrying its scope across. 246 247 The answer to a leak and to a token that has already expired. 248 The plaintext comes back exactly once, the predecessor is 249 revoked in the same operation, and the reply's ``id`` is a new 250 one — a rotation is two linked rows, so an audit can still 251 resolve the old secret's hash. 252 """ 253 body: dict[str, object] = {} 254 if expires_in_days is not None: 255 body["expires_in_days"] = expires_in_days 256 resp = self._http.request("POST", f"/auth/tokens/{token_id}/rotate", json=body) 257 _raise_for_status(resp) 258 return CreateFineGrainedTokenResponse.model_validate(resp.json())
Replace a token's secret, carrying its scope across.
The answer to a leak and to a token that has already expired.
The plaintext comes back exactly once, the predecessor is
revoked in the same operation, and the reply's id is a new
one — a rotation is two linked rows, so an audit can still
resolve the old secret's hash.
260 def revoke_token(self, token_id: str) -> None: 261 """Revoke one token by id.""" 262 resp = self._http.request("DELETE", f"/auth/tokens/{token_id}") 263 _raise_for_status(resp)
Revoke one token by id.
265 def revoke_other_tokens(self) -> None: 266 """Revoke every token belonging to this user except the caller's own.""" 267 resp = self._http.request("DELETE", "/auth/tokens/others") 268 _raise_for_status(resp)
Revoke every token belonging to this user except the caller's own.
270 def providers(self) -> ProvidersResponse: 271 """Which sign-in methods this deployment offers (OAuth providers 272 configured + whether password auth is enabled).""" 273 resp = self._http.request("GET", "/auth/providers") 274 _raise_for_status(resp) 275 return ProvidersResponse.model_validate(resp.json())
Which sign-in methods this deployment offers (OAuth providers configured + whether password auth is enabled).
277 def token_scopes(self) -> TokenScopesResponse: 278 """The catalog of grantable fine-grained token scopes — drives 279 the token-create wizard.""" 280 resp = self._http.request("GET", "/auth/tokens/scopes") 281 _raise_for_status(resp) 282 return TokenScopesResponse.model_validate(resp.json())
The catalog of grantable fine-grained token scopes — drives the token-create wizard.
284 def request_email_code(self, email: str) -> dict[str, object]: 285 """Send a one-time sign-in code to ``email``. 286 287 Always reports success for a well-formed address whether or not 288 an account exists — the endpoint is deliberately not an account 289 enumeration oracle, and verification creates the account if 290 needed. 291 """ 292 resp = self._http.request("POST", "/auth/email-code", json={"email": email}) 293 _raise_for_status(resp) 294 return dict(resp.json())
Send a one-time sign-in code to email.
Always reports success for a well-formed address whether or not an account exists — the endpoint is deliberately not an account enumeration oracle, and verification creates the account if needed.
296 def verify_email_code(self, email: str, code: str) -> AuthTokenResponse: 297 """Exchange a sign-in code for a session token. 298 299 Creates the account on first use, which also verifies the address 300 by construction — unlike password registration, which never did. 301 """ 302 resp = self._http.request( 303 "POST", "/auth/email-code/verify", json={"email": email, "code": code} 304 ) 305 _raise_for_status(resp) 306 return AuthTokenResponse.model_validate(resp.json())
Exchange a sign-in code for a session token.
Creates the account on first use, which also verifies the address by construction — unlike password registration, which never did.
308 def request_email_verification(self) -> EmailVerificationSent: 309 """Mail a verification code to the calling account's own address. 310 311 ⚠️ **Takes no address.** The code goes to whatever is already on 312 the account, which is what stops this being a way to have the 313 platform mail arbitrary inboxes and what stops a caller 314 attaching somebody else's address to their own account as 315 verified. 316 317 Wrapped rather than left browser-only because a deployment that 318 sets ``email_verification_required`` refuses *every* write from 319 an unproved account — including from a ``user``-kind token minted 320 by ``ctfy login``, which is exactly the credential this client 321 holds. Without these two methods a CLI user on such a deployment 322 would have no way forward that did not involve a browser. 323 """ 324 resp = self._http.request("POST", "/auth/email-verification") 325 _raise_for_status(resp) 326 return EmailVerificationSent.model_validate(resp.json())
Mail a verification code to the calling account's own address.
⚠️ Takes no address. The code goes to whatever is already on the account, which is what stops this being a way to have the platform mail arbitrary inboxes and what stops a caller attaching somebody else's address to their own account as verified.
Wrapped rather than left browser-only because a deployment that
sets email_verification_required refuses every write from
an unproved account — including from a user-kind token minted
by ctfy login, which is exactly the credential this client
holds. Without these two methods a CLI user on such a deployment
would have no way forward that did not involve a browser.
328 def email_verification_status(self) -> EmailVerificationStatus: 329 """Where the account stands, including whether a code is outstanding. 330 331 ``code_sent_at`` / ``code_expires_at`` are ``None`` when no 332 redeemable code exists (never sent, the send failed, expired). 333 """ 334 resp = self._http.request("GET", "/auth/email-verification") 335 _raise_for_status(resp) 336 return EmailVerificationStatus.model_validate(resp.json())
Where the account stands, including whether a code is outstanding.
code_sent_at / code_expires_at are None when no
redeemable code exists (never sent, the send failed, expired).
338 def list_emails(self) -> list[UserEmailInfo]: 339 """Every address on the calling account, oldest claim first. 340 341 ``users.email`` is the *primary* — the address the platform mails 342 and the one ``email_verification_required`` rules on — and these 343 rows are the set it is chosen from, each carrying its own proof. 344 """ 345 resp = self._http.request("GET", "/me/emails") 346 _raise_for_status(resp) 347 return [UserEmailInfo.model_validate(e) for e in resp.json()["items"]]
Every address on the calling account, oldest claim first.
users.email is the primary — the address the platform mails
and the one email_verification_required rules on — and these
rows are the set it is chosen from, each carrying its own proof.
349 def add_email(self, email: str) -> EmailVerificationSent: 350 """Mail a code to an address you want to attach to this account. 351 352 ⚠️ **Unlike :meth:`request_email_verification`, this one does take 353 an address, and the difference is where the code goes.** That one 354 mails whatever is already on the account, so a parameter would 355 make it a way to mail arbitrary inboxes on demand. Here the code 356 goes *to the address you name*, so the exchange proves nothing 357 unless you can read that mailbox — naming it is the act. 358 359 ⚠️ **Read ``handover`` on the answer before redeeming the code.** 360 An address another account already holds is not refused here; the 361 exchange *moves* it. ``"orphans"`` means that account has no 362 other way in and will be unreachable afterwards, which 363 :meth:`verify_added_email` refuses unless you say 364 ``acknowledge_orphan=True``. 365 """ 366 resp = self._http.request("POST", "/me/emails", json={"email": email}) 367 _raise_for_status(resp) 368 return EmailVerificationSent.model_validate(resp.json())
Mail a code to an address you want to attach to this account.
⚠️ Unlike request_email_verification(), this one does take
an address, and the difference is where the code goes. That one
mails whatever is already on the account, so a parameter would
make it a way to mail arbitrary inboxes on demand. Here the code
goes to the address you name, so the exchange proves nothing
unless you can read that mailbox — naming it is the act.
⚠️ Read handover on the answer before redeeming the code.
An address another account already holds is not refused here; the
exchange moves it. "orphans" means that account has no
other way in and will be unreachable afterwards, which
verify_added_email() refuses unless you say
acknowledge_orphan=True.
370 def verify_added_email( 371 self, email: str, code: str, *, acknowledge_orphan: bool = False 372 ) -> UserEmailInfo: 373 """Exchange the code for a proved address on this account. 374 375 Carries the address as well as the code because the code table is 376 keyed by ``(address, purpose)`` — asking which address this code 377 is for *is* the lookup, not a convenience. 378 379 ⚠️ **``acknowledge_orphan`` defaults to refusing**, and the empty 380 case has to: a caller that never heard of handovers must not be 381 able to leave another account unreachable by omission. It is 382 needed only when :meth:`add_email` answered ``"orphans"`` — every 383 other move is free, and the losing account is mailed either way. 384 """ 385 resp = self._http.request( 386 "POST", 387 "/me/emails/verify", 388 json={"email": email, "code": code, "acknowledge_orphan": acknowledge_orphan}, 389 ) 390 _raise_for_status(resp) 391 return UserEmailInfo.model_validate(resp.json())
Exchange the code for a proved address on this account.
Carries the address as well as the code because the code table is
keyed by (address, purpose) — asking which address this code
is for is the lookup, not a convenience.
⚠️ acknowledge_orphan defaults to refusing, and the empty
case has to: a caller that never heard of handovers must not be
able to leave another account unreachable by omission. It is
needed only when add_email() answered "orphans" — every
other move is free, and the losing account is mailed either way.
393 def set_primary_email(self, email: str) -> UserEmailInfo: 394 """Point the account's primary address at one of its proved ones. 395 396 The verification stamp moves with the address; only a proved 397 address may be chosen, because the primary is where the platform 398 writes and what the verification gate rules on. 399 """ 400 resp = self._http.request("POST", f"/me/emails/{quote(email, safe='')}/primary") 401 _raise_for_status(resp) 402 return UserEmailInfo.model_validate(resp.json())
Point the account's primary address at one of its proved ones.
The verification stamp moves with the address; only a proved address may be chosen, because the primary is where the platform writes and what the verification gate rules on.
404 def remove_email(self, email: str) -> None: 405 """Drop a secondary address. The primary is refused — make another 406 one primary first.""" 407 resp = self._http.request("DELETE", f"/me/emails/{quote(email, safe='')}") 408 _raise_for_status(resp)
Drop a secondary address. The primary is refused — make another one primary first.
410 def publish_affiliation(self, email: str) -> AffiliationSelection: 411 """Show an institution badge derived from one proved address. 412 413 Independent of :meth:`set_primary_email` — two choices over one 414 set of addresses, the mailbox the platform writes to and the one 415 a stranger's badge is derived from. Only a *proved* address is 416 accepted (409 otherwise): the badge is a published claim about a 417 person, so the platform shows only what it checked. 418 419 Nothing about the institution is stored. It is resolved on every 420 read, so the badge disappears by itself when the address is 421 removed, loses its proof, is handed to another account, or the 422 operator deletes the catalogue row. 423 """ 424 resp = self._http.request("POST", f"/me/emails/{quote(email, safe='')}/affiliation") 425 _raise_for_status(resp) 426 return AffiliationSelection.model_validate(resp.json())
Show an institution badge derived from one proved address.
Independent of set_primary_email() — two choices over one
set of addresses, the mailbox the platform writes to and the one
a stranger's badge is derived from. Only a proved address is
accepted (409 otherwise): the badge is a published claim about a
person, so the platform shows only what it checked.
Nothing about the institution is stored. It is resolved on every read, so the badge disappears by itself when the address is removed, loses its proof, is handed to another account, or the operator deletes the catalogue row.
428 def clear_affiliation(self) -> AffiliationSelection: 429 """Stop showing a badge. 430 431 Takes no address, and is therefore never refused: a version 432 addressed by address would refuse to stop publishing a badge 433 derived from an address that has already been removed. 434 """ 435 resp = self._http.request("DELETE", "/me/affiliation") 436 _raise_for_status(resp) 437 return AffiliationSelection.model_validate(resp.json())
Stop showing a badge.
Takes no address, and is therefore never refused: a version addressed by address would refuse to stop publishing a badge derived from an address that has already been removed.
439 def submit_email_verification(self, code: str) -> EmailVerificationStatus: 440 """Exchange the code for the verified stamp. 441 442 Every rejection is the same ``invalid_code``, deliberately: 443 separating "wrong" from "expired" from "already used" tells an 444 attacker which guesses were structurally close and tells a 445 legitimate caller nothing beyond "request a new one". 446 """ 447 resp = self._http.request("POST", "/auth/email-verification/verify", json={"code": code}) 448 _raise_for_status(resp) 449 return EmailVerificationStatus.model_validate(resp.json())
Exchange the code for the verified stamp.
Every rejection is the same invalid_code, deliberately:
separating "wrong" from "expired" from "already used" tells an
attacker which guesses were structurally close and tells a
legitimate caller nothing beyond "request a new one".