ctfy.sdk.resources.auth

client.auth — password auth, OAuth identities, API tokens, discovery.

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

Register / login, identity binding, token CRUD, sign-in discovery.

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 email_verification_status(self) -> ctfy.server.models.EmailVerificationStatus:
328    def email_verification_status(self) -> EmailVerificationStatus:
329        """Where the account stands, including whether a code is outstanding.
330
331        ``code_sent_at`` / ``code_expires_at`` are ``None`` when no
332        redeemable code exists (never sent, the send failed, expired).
333        """
334        resp = self._http.request("GET", "/auth/email-verification")
335        _raise_for_status(resp)
336        return EmailVerificationStatus.model_validate(resp.json())

Where the account stands, including whether a code is outstanding.

code_sent_at / code_expires_at are None when no redeemable code exists (never sent, the send failed, expired).

def list_emails(self) -> list[ctfy.server.models.UserEmailInfo]:
338    def list_emails(self) -> list[UserEmailInfo]:
339        """Every address on the calling account, oldest claim first.
340
341        ``users.email`` is the *primary* — the address the platform mails
342        and the one ``email_verification_required`` rules on — and these
343        rows are the set it is chosen from, each carrying its own proof.
344        """
345        resp = self._http.request("GET", "/me/emails")
346        _raise_for_status(resp)
347        return [UserEmailInfo.model_validate(e) for e in resp.json()["items"]]

Every address on the calling account, oldest claim first.

users.email is the primary — the address the platform mails and the one email_verification_required rules on — and these rows are the set it is chosen from, each carrying its own proof.

def add_email(self, email: str) -> ctfy.server.models.EmailVerificationSent:
349    def add_email(self, email: str) -> EmailVerificationSent:
350        """Mail a code to an address you want to attach to this account.
351
352        ⚠️ **Unlike :meth:`request_email_verification`, this one does take
353        an address, and the difference is where the code goes.** That one
354        mails whatever is already on the account, so a parameter would
355        make it a way to mail arbitrary inboxes on demand. Here the code
356        goes *to the address you name*, so the exchange proves nothing
357        unless you can read that mailbox — naming it is the act.
358
359        ⚠️ **Read ``handover`` on the answer before redeeming the code.**
360        An address another account already holds is not refused here; the
361        exchange *moves* it. ``"orphans"`` means that account has no
362        other way in and will be unreachable afterwards, which
363        :meth:`verify_added_email` refuses unless you say
364        ``acknowledge_orphan=True``.
365        """
366        resp = self._http.request("POST", "/me/emails", json={"email": email})
367        _raise_for_status(resp)
368        return EmailVerificationSent.model_validate(resp.json())

Mail a code to an address you want to attach to this account.

⚠️ Unlike request_email_verification(), this one does take an address, and the difference is where the code goes. That one mails whatever is already on the account, so a parameter would make it a way to mail arbitrary inboxes on demand. Here the code goes to the address you name, so the exchange proves nothing unless you can read that mailbox — naming it is the act.

⚠️ Read handover on the answer before redeeming the code. An address another account already holds is not refused here; the exchange moves it. "orphans" means that account has no other way in and will be unreachable afterwards, which verify_added_email() refuses unless you say acknowledge_orphan=True.

def verify_added_email( self, email: str, code: str, *, acknowledge_orphan: bool = False) -> ctfy.server.models.UserEmailInfo:
370    def verify_added_email(
371        self, email: str, code: str, *, acknowledge_orphan: bool = False
372    ) -> UserEmailInfo:
373        """Exchange the code for a proved address on this account.
374
375        Carries the address as well as the code because the code table is
376        keyed by ``(address, purpose)`` — asking which address this code
377        is for *is* the lookup, not a convenience.
378
379        ⚠️ **``acknowledge_orphan`` defaults to refusing**, and the empty
380        case has to: a caller that never heard of handovers must not be
381        able to leave another account unreachable by omission. It is
382        needed only when :meth:`add_email` answered ``"orphans"`` — every
383        other move is free, and the losing account is mailed either way.
384        """
385        resp = self._http.request(
386            "POST",
387            "/me/emails/verify",
388            json={"email": email, "code": code, "acknowledge_orphan": acknowledge_orphan},
389        )
390        _raise_for_status(resp)
391        return UserEmailInfo.model_validate(resp.json())

Exchange the code for a proved address on this account.

Carries the address as well as the code because the code table is keyed by (address, purpose) — asking which address this code is for is the lookup, not a convenience.

⚠️ acknowledge_orphan defaults to refusing, and the empty case has to: a caller that never heard of handovers must not be able to leave another account unreachable by omission. It is needed only when add_email() answered "orphans" — every other move is free, and the losing account is mailed either way.

def set_primary_email(self, email: str) -> ctfy.server.models.UserEmailInfo:
393    def set_primary_email(self, email: str) -> UserEmailInfo:
394        """Point the account's primary address at one of its proved ones.
395
396        The verification stamp moves with the address; only a proved
397        address may be chosen, because the primary is where the platform
398        writes and what the verification gate rules on.
399        """
400        resp = self._http.request("POST", f"/me/emails/{quote(email, safe='')}/primary")
401        _raise_for_status(resp)
402        return UserEmailInfo.model_validate(resp.json())

Point the account's primary address at one of its proved ones.

The verification stamp moves with the address; only a proved address may be chosen, because the primary is where the platform writes and what the verification gate rules on.

def remove_email(self, email: str) -> None:
404    def remove_email(self, email: str) -> None:
405        """Drop a secondary address. The primary is refused — make another
406        one primary first."""
407        resp = self._http.request("DELETE", f"/me/emails/{quote(email, safe='')}")
408        _raise_for_status(resp)

Drop a secondary address. The primary is refused — make another one primary first.

def publish_affiliation(self, email: str) -> ctfy.server.models.AffiliationSelection:
410    def publish_affiliation(self, email: str) -> AffiliationSelection:
411        """Show an institution badge derived from one proved address.
412
413        Independent of :meth:`set_primary_email` — two choices over one
414        set of addresses, the mailbox the platform writes to and the one
415        a stranger's badge is derived from. Only a *proved* address is
416        accepted (409 otherwise): the badge is a published claim about a
417        person, so the platform shows only what it checked.
418
419        Nothing about the institution is stored. It is resolved on every
420        read, so the badge disappears by itself when the address is
421        removed, loses its proof, is handed to another account, or the
422        operator deletes the catalogue row.
423        """
424        resp = self._http.request("POST", f"/me/emails/{quote(email, safe='')}/affiliation")
425        _raise_for_status(resp)
426        return AffiliationSelection.model_validate(resp.json())

Show an institution badge derived from one proved address.

Independent of set_primary_email() — two choices over one set of addresses, the mailbox the platform writes to and the one a stranger's badge is derived from. Only a proved address is accepted (409 otherwise): the badge is a published claim about a person, so the platform shows only what it checked.

Nothing about the institution is stored. It is resolved on every read, so the badge disappears by itself when the address is removed, loses its proof, is handed to another account, or the operator deletes the catalogue row.

def clear_affiliation(self) -> ctfy.server.models.AffiliationSelection:
428    def clear_affiliation(self) -> AffiliationSelection:
429        """Stop showing a badge.
430
431        Takes no address, and is therefore never refused: a version
432        addressed by address would refuse to stop publishing a badge
433        derived from an address that has already been removed.
434        """
435        resp = self._http.request("DELETE", "/me/affiliation")
436        _raise_for_status(resp)
437        return AffiliationSelection.model_validate(resp.json())

Stop showing a badge.

Takes no address, and is therefore never refused: a version addressed by address would refuse to stop publishing a badge derived from an address that has already been removed.

def submit_email_verification(self, code: str) -> ctfy.server.models.EmailVerificationStatus:
439    def submit_email_verification(self, code: str) -> EmailVerificationStatus:
440        """Exchange the code for the verified stamp.
441
442        Every rejection is the same ``invalid_code``, deliberately:
443        separating "wrong" from "expired" from "already used" tells an
444        attacker which guesses were structurally close and tells a
445        legitimate caller nothing beyond "request a new one".
446        """
447        resp = self._http.request("POST", "/auth/email-verification/verify", json={"code": code})
448        _raise_for_status(resp)
449        return EmailVerificationStatus.model_validate(resp.json())

Exchange the code for the verified stamp.

Every rejection is the same invalid_code, deliberately: separating "wrong" from "expired" from "already used" tells an attacker which guesses were structurally close and tells a legitimate caller nothing beyond "request a new one".