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())
class AuthResource:
 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.

AuthResource(http: ctfy.sdk.base.BaseHttpClient)
31    def __init__(self, http: BaseHttpClient) -> None:
32        self._http = http
def register( self, email: str, password: str, display_name: str = '') -> ctfy.server.models.AuthTokenResponse:
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".

def login( self, email: str, password: str) -> ctfy.server.models.AuthTokenResponse:
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.

def device_code(self) -> ctfy.server.models.DeviceCodeResponse:
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.

def device_token(self, device_code: str) -> ctfy.server.models.DeviceTokenResponse:
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.

def set_password(self, new_password: str, current_password: str = '') -> None:
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.

def delete_password(self) -> None:
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.

def list_identities(self) -> list[ctfy.server.models.LinkedIdentity]:
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.

def unbind_identity(self, identity_id: str) -> None:
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.

def list_tokens( self, offset: int = 0, limit: int = 50) -> list[ctfy.server.models.TokenInfo]:
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.

def token_templates(self) -> ctfy.server.models.TokenTemplatesResponse:
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.

def mint_token( self, label: str, expires_in_days: int | None = None, *, template: str | None = None, competition_access: str | None = None, competition_ids: list[str] | None = None, permissions: dict[str, str] | None = None) -> ctfy.server.models.CreateFineGrainedTokenResponse:
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.

def update_token_scopes( self, token_id: str, *, label: str | None = None, template: str | None = None, competition_access: str | None = None, competition_ids: list[str] | None = None, permissions: dict[str, str] | None = None) -> ctfy.server.models.TokenInfo:
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.

def renew_token( self, token_id: str, expires_in_days: int | None = None) -> ctfy.server.models.TokenInfo:
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.

def rotate_token( self, token_id: str, expires_in_days: int | None = None) -> ctfy.server.models.CreateFineGrainedTokenResponse:
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.

def revoke_token(self, token_id: str) -> None:
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.

def revoke_other_tokens(self) -> None:
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.

def providers(self) -> ctfy.server.models.ProvidersResponse:
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).

def token_scopes(self) -> ctfy.server.models.TokenScopesResponse:
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.

def request_email_code(self, email: str) -> dict[str, object]:
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.

def verify_email_code(self, email: str, code: str) -> ctfy.server.models.AuthTokenResponse:
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.

def request_email_verification(self) -> ctfy.server.models.EmailVerificationSent:
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.

def list_emails(self) -> list[ctfy.server.models.UserEmailInfo]:
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.

def add_email(self, email: str) -> ctfy.server.models.EmailVerificationSent:
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.

def verify_added_email( self, email: str, code: str, *, acknowledge_orphan: bool = False) -> ctfy.server.models.UserEmailInfo:
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.

def set_primary_email(self, email: str) -> ctfy.server.models.UserEmailInfo:
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.

def remove_email(self, email: str) -> None:
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.

def publish_affiliation(self, email: str) -> ctfy.server.models.AffiliationSelection:
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.

def clear_affiliation(self) -> ctfy.server.models.AffiliationSelection:
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.

def submit_email_verification(self, code: str) -> ctfy.server.models.EmailVerificationStatus:
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".