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 list_emails(self) -> list[UserEmailInfo]: 328 """Every address on the calling account, oldest claim first. 329 330 ``users.email`` is the *primary* — the address the platform mails 331 and the one ``email_verification_required`` rules on — and these 332 rows are the set it is chosen from, each carrying its own proof. 333 """ 334 resp = self._http.request("GET", "/me/emails") 335 _raise_for_status(resp) 336 return [UserEmailInfo.model_validate(e) for e in resp.json()["items"]] 337 338 def add_email(self, email: str) -> EmailVerificationSent: 339 """Mail a code to an address you want to attach to this account. 340 341 ⚠️ **Unlike :meth:`request_email_verification`, this one does take 342 an address, and the difference is where the code goes.** That one 343 mails whatever is already on the account, so a parameter would 344 make it a way to mail arbitrary inboxes on demand. Here the code 345 goes *to the address you name*, so the exchange proves nothing 346 unless you can read that mailbox — naming it is the act. 347 348 ⚠️ **Read ``handover`` on the answer before redeeming the code.** 349 An address another account already holds is not refused here; the 350 exchange *moves* it. ``"orphans"`` means that account has no 351 other way in and will be unreachable afterwards, which 352 :meth:`verify_added_email` refuses unless you say 353 ``acknowledge_orphan=True``. 354 """ 355 resp = self._http.request("POST", "/me/emails", json={"email": email}) 356 _raise_for_status(resp) 357 return EmailVerificationSent.model_validate(resp.json()) 358 359 def verify_added_email( 360 self, email: str, code: str, *, acknowledge_orphan: bool = False 361 ) -> UserEmailInfo: 362 """Exchange the code for a proved address on this account. 363 364 Carries the address as well as the code because the code table is 365 keyed by ``(address, purpose)`` — asking which address this code 366 is for *is* the lookup, not a convenience. 367 368 ⚠️ **``acknowledge_orphan`` defaults to refusing**, and the empty 369 case has to: a caller that never heard of handovers must not be 370 able to leave another account unreachable by omission. It is 371 needed only when :meth:`add_email` answered ``"orphans"`` — every 372 other move is free, and the losing account is mailed either way. 373 """ 374 resp = self._http.request( 375 "POST", 376 "/me/emails/verify", 377 json={"email": email, "code": code, "acknowledge_orphan": acknowledge_orphan}, 378 ) 379 _raise_for_status(resp) 380 return UserEmailInfo.model_validate(resp.json()) 381 382 def set_primary_email(self, email: str) -> UserEmailInfo: 383 """Point the account's primary address at one of its proved ones. 384 385 The verification stamp moves with the address; only a proved 386 address may be chosen, because the primary is where the platform 387 writes and what the verification gate rules on. 388 """ 389 resp = self._http.request("POST", f"/me/emails/{quote(email, safe='')}/primary") 390 _raise_for_status(resp) 391 return UserEmailInfo.model_validate(resp.json()) 392 393 def remove_email(self, email: str) -> None: 394 """Drop a secondary address. The primary is refused — make another 395 one primary first.""" 396 resp = self._http.request("DELETE", f"/me/emails/{quote(email, safe='')}") 397 _raise_for_status(resp) 398 399 def publish_affiliation(self, email: str) -> AffiliationSelection: 400 """Show an institution badge derived from one proved address. 401 402 Independent of :meth:`set_primary_email` — two choices over one 403 set of addresses, the mailbox the platform writes to and the one 404 a stranger's badge is derived from. Only a *proved* address is 405 accepted (409 otherwise): the badge is a published claim about a 406 person, so the platform shows only what it checked. 407 408 Nothing about the institution is stored. It is resolved on every 409 read, so the badge disappears by itself when the address is 410 removed, loses its proof, is handed to another account, or the 411 operator deletes the catalogue row. 412 """ 413 resp = self._http.request("POST", f"/me/emails/{quote(email, safe='')}/affiliation") 414 _raise_for_status(resp) 415 return AffiliationSelection.model_validate(resp.json()) 416 417 def clear_affiliation(self) -> AffiliationSelection: 418 """Stop showing a badge. 419 420 Takes no address, and is therefore never refused: a version 421 addressed by address would refuse to stop publishing a badge 422 derived from an address that has already been removed. 423 """ 424 resp = self._http.request("DELETE", "/me/affiliation") 425 _raise_for_status(resp) 426 return AffiliationSelection.model_validate(resp.json()) 427 428 def submit_email_verification(self, code: str) -> EmailVerificationStatus: 429 """Exchange the code for the verified stamp. 430 431 Every rejection is the same ``invalid_code``, deliberately: 432 separating "wrong" from "expired" from "already used" tells an 433 attacker which guesses were structurally close and tells a 434 legitimate caller nothing beyond "request a new one". 435 """ 436 resp = self._http.request("POST", "/auth/email-verification/verify", json={"code": code}) 437 _raise_for_status(resp) 438 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 list_emails(self) -> list[UserEmailInfo]: 329 """Every address on the calling account, oldest claim first. 330 331 ``users.email`` is the *primary* — the address the platform mails 332 and the one ``email_verification_required`` rules on — and these 333 rows are the set it is chosen from, each carrying its own proof. 334 """ 335 resp = self._http.request("GET", "/me/emails") 336 _raise_for_status(resp) 337 return [UserEmailInfo.model_validate(e) for e in resp.json()["items"]] 338 339 def add_email(self, email: str) -> EmailVerificationSent: 340 """Mail a code to an address you want to attach to this account. 341 342 ⚠️ **Unlike :meth:`request_email_verification`, this one does take 343 an address, and the difference is where the code goes.** That one 344 mails whatever is already on the account, so a parameter would 345 make it a way to mail arbitrary inboxes on demand. Here the code 346 goes *to the address you name*, so the exchange proves nothing 347 unless you can read that mailbox — naming it is the act. 348 349 ⚠️ **Read ``handover`` on the answer before redeeming the code.** 350 An address another account already holds is not refused here; the 351 exchange *moves* it. ``"orphans"`` means that account has no 352 other way in and will be unreachable afterwards, which 353 :meth:`verify_added_email` refuses unless you say 354 ``acknowledge_orphan=True``. 355 """ 356 resp = self._http.request("POST", "/me/emails", json={"email": email}) 357 _raise_for_status(resp) 358 return EmailVerificationSent.model_validate(resp.json()) 359 360 def verify_added_email( 361 self, email: str, code: str, *, acknowledge_orphan: bool = False 362 ) -> UserEmailInfo: 363 """Exchange the code for a proved address on this account. 364 365 Carries the address as well as the code because the code table is 366 keyed by ``(address, purpose)`` — asking which address this code 367 is for *is* the lookup, not a convenience. 368 369 ⚠️ **``acknowledge_orphan`` defaults to refusing**, and the empty 370 case has to: a caller that never heard of handovers must not be 371 able to leave another account unreachable by omission. It is 372 needed only when :meth:`add_email` answered ``"orphans"`` — every 373 other move is free, and the losing account is mailed either way. 374 """ 375 resp = self._http.request( 376 "POST", 377 "/me/emails/verify", 378 json={"email": email, "code": code, "acknowledge_orphan": acknowledge_orphan}, 379 ) 380 _raise_for_status(resp) 381 return UserEmailInfo.model_validate(resp.json()) 382 383 def set_primary_email(self, email: str) -> UserEmailInfo: 384 """Point the account's primary address at one of its proved ones. 385 386 The verification stamp moves with the address; only a proved 387 address may be chosen, because the primary is where the platform 388 writes and what the verification gate rules on. 389 """ 390 resp = self._http.request("POST", f"/me/emails/{quote(email, safe='')}/primary") 391 _raise_for_status(resp) 392 return UserEmailInfo.model_validate(resp.json()) 393 394 def remove_email(self, email: str) -> None: 395 """Drop a secondary address. The primary is refused — make another 396 one primary first.""" 397 resp = self._http.request("DELETE", f"/me/emails/{quote(email, safe='')}") 398 _raise_for_status(resp) 399 400 def publish_affiliation(self, email: str) -> AffiliationSelection: 401 """Show an institution badge derived from one proved address. 402 403 Independent of :meth:`set_primary_email` — two choices over one 404 set of addresses, the mailbox the platform writes to and the one 405 a stranger's badge is derived from. Only a *proved* address is 406 accepted (409 otherwise): the badge is a published claim about a 407 person, so the platform shows only what it checked. 408 409 Nothing about the institution is stored. It is resolved on every 410 read, so the badge disappears by itself when the address is 411 removed, loses its proof, is handed to another account, or the 412 operator deletes the catalogue row. 413 """ 414 resp = self._http.request("POST", f"/me/emails/{quote(email, safe='')}/affiliation") 415 _raise_for_status(resp) 416 return AffiliationSelection.model_validate(resp.json()) 417 418 def clear_affiliation(self) -> AffiliationSelection: 419 """Stop showing a badge. 420 421 Takes no address, and is therefore never refused: a version 422 addressed by address would refuse to stop publishing a badge 423 derived from an address that has already been removed. 424 """ 425 resp = self._http.request("DELETE", "/me/affiliation") 426 _raise_for_status(resp) 427 return AffiliationSelection.model_validate(resp.json()) 428 429 def submit_email_verification(self, code: str) -> EmailVerificationStatus: 430 """Exchange the code for the verified stamp. 431 432 Every rejection is the same ``invalid_code``, deliberately: 433 separating "wrong" from "expired" from "already used" tells an 434 attacker which guesses were structurally close and tells a 435 legitimate caller nothing beyond "request a new one". 436 """ 437 resp = self._http.request("POST", "/auth/email-verification/verify", json={"code": code}) 438 _raise_for_status(resp) 439 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 list_emails(self) -> list[UserEmailInfo]: 329 """Every address on the calling account, oldest claim first. 330 331 ``users.email`` is the *primary* — the address the platform mails 332 and the one ``email_verification_required`` rules on — and these 333 rows are the set it is chosen from, each carrying its own proof. 334 """ 335 resp = self._http.request("GET", "/me/emails") 336 _raise_for_status(resp) 337 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.
339 def add_email(self, email: str) -> EmailVerificationSent: 340 """Mail a code to an address you want to attach to this account. 341 342 ⚠️ **Unlike :meth:`request_email_verification`, this one does take 343 an address, and the difference is where the code goes.** That one 344 mails whatever is already on the account, so a parameter would 345 make it a way to mail arbitrary inboxes on demand. Here the code 346 goes *to the address you name*, so the exchange proves nothing 347 unless you can read that mailbox — naming it is the act. 348 349 ⚠️ **Read ``handover`` on the answer before redeeming the code.** 350 An address another account already holds is not refused here; the 351 exchange *moves* it. ``"orphans"`` means that account has no 352 other way in and will be unreachable afterwards, which 353 :meth:`verify_added_email` refuses unless you say 354 ``acknowledge_orphan=True``. 355 """ 356 resp = self._http.request("POST", "/me/emails", json={"email": email}) 357 _raise_for_status(resp) 358 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.
360 def verify_added_email( 361 self, email: str, code: str, *, acknowledge_orphan: bool = False 362 ) -> UserEmailInfo: 363 """Exchange the code for a proved address on this account. 364 365 Carries the address as well as the code because the code table is 366 keyed by ``(address, purpose)`` — asking which address this code 367 is for *is* the lookup, not a convenience. 368 369 ⚠️ **``acknowledge_orphan`` defaults to refusing**, and the empty 370 case has to: a caller that never heard of handovers must not be 371 able to leave another account unreachable by omission. It is 372 needed only when :meth:`add_email` answered ``"orphans"`` — every 373 other move is free, and the losing account is mailed either way. 374 """ 375 resp = self._http.request( 376 "POST", 377 "/me/emails/verify", 378 json={"email": email, "code": code, "acknowledge_orphan": acknowledge_orphan}, 379 ) 380 _raise_for_status(resp) 381 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.
383 def set_primary_email(self, email: str) -> UserEmailInfo: 384 """Point the account's primary address at one of its proved ones. 385 386 The verification stamp moves with the address; only a proved 387 address may be chosen, because the primary is where the platform 388 writes and what the verification gate rules on. 389 """ 390 resp = self._http.request("POST", f"/me/emails/{quote(email, safe='')}/primary") 391 _raise_for_status(resp) 392 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.
394 def remove_email(self, email: str) -> None: 395 """Drop a secondary address. The primary is refused — make another 396 one primary first.""" 397 resp = self._http.request("DELETE", f"/me/emails/{quote(email, safe='')}") 398 _raise_for_status(resp)
Drop a secondary address. The primary is refused — make another one primary first.
400 def publish_affiliation(self, email: str) -> AffiliationSelection: 401 """Show an institution badge derived from one proved address. 402 403 Independent of :meth:`set_primary_email` — two choices over one 404 set of addresses, the mailbox the platform writes to and the one 405 a stranger's badge is derived from. Only a *proved* address is 406 accepted (409 otherwise): the badge is a published claim about a 407 person, so the platform shows only what it checked. 408 409 Nothing about the institution is stored. It is resolved on every 410 read, so the badge disappears by itself when the address is 411 removed, loses its proof, is handed to another account, or the 412 operator deletes the catalogue row. 413 """ 414 resp = self._http.request("POST", f"/me/emails/{quote(email, safe='')}/affiliation") 415 _raise_for_status(resp) 416 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.
418 def clear_affiliation(self) -> AffiliationSelection: 419 """Stop showing a badge. 420 421 Takes no address, and is therefore never refused: a version 422 addressed by address would refuse to stop publishing a badge 423 derived from an address that has already been removed. 424 """ 425 resp = self._http.request("DELETE", "/me/affiliation") 426 _raise_for_status(resp) 427 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.
429 def submit_email_verification(self, code: str) -> EmailVerificationStatus: 430 """Exchange the code for the verified stamp. 431 432 Every rejection is the same ``invalid_code``, deliberately: 433 separating "wrong" from "expired" from "already used" tells an 434 attacker which guesses were structurally close and tells a 435 legitimate caller nothing beyond "request a new one". 436 """ 437 resp = self._http.request("POST", "/auth/email-verification/verify", json={"code": code}) 438 _raise_for_status(resp) 439 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".