icpc-api 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- icpc/__init__.py +32 -0
- icpc/api/__init__.py +18 -0
- icpc/api/common.py +97 -0
- icpc/api/contest.py +253 -0
- icpc/api/person.py +139 -0
- icpc/api/public.py +65 -0
- icpc/api/staff.py +83 -0
- icpc/api/team.py +335 -0
- icpc/auth/__init__.py +28 -0
- icpc/auth/cognito.py +142 -0
- icpc/auth/flows.py +314 -0
- icpc/auth/provider.py +29 -0
- icpc/auth/srp.py +185 -0
- icpc/auth/store.py +223 -0
- icpc/auth/tokens.py +86 -0
- icpc/cli/__init__.py +20 -0
- icpc/cli/columns.py +555 -0
- icpc/cli/main.py +1156 -0
- icpc/cli/render.py +160 -0
- icpc/config.py +57 -0
- icpc/errors.py +159 -0
- icpc/facade/__init__.py +6 -0
- icpc/facade/client.py +606 -0
- icpc/facade/domain.py +198 -0
- icpc/models/__init__.py +60 -0
- icpc/models/_generated.py +566 -0
- icpc/models/base.py +41 -0
- icpc/models/blobs.py +81 -0
- icpc/models/common.py +61 -0
- icpc/models/entities.py +522 -0
- icpc/models/enums.py +192 -0
- icpc/models/mixins.py +44 -0
- icpc/py.typed +0 -0
- icpc/search/__init__.py +99 -0
- icpc/search/_generated.py +1814 -0
- icpc/search/dsl.py +124 -0
- icpc/search/endpoint.py +173 -0
- icpc/search/fields.py +59 -0
- icpc/transport/__init__.py +29 -0
- icpc/transport/_shared.py +121 -0
- icpc/transport/async_client.py +120 -0
- icpc/transport/operation.py +139 -0
- icpc/transport/sync_client.py +121 -0
- icpc_api-0.1.0.dist-info/METADATA +143 -0
- icpc_api-0.1.0.dist-info/RECORD +50 -0
- icpc_api-0.1.0.dist-info/WHEEL +5 -0
- icpc_api-0.1.0.dist-info/entry_points.txt +2 -0
- icpc_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- icpc_api-0.1.0.dist-info/licenses/THIRD-PARTY-LICENSES.md +220 -0
- icpc_api-0.1.0.dist-info/top_level.txt +1 -0
icpc/api/team.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""``/team`` endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import TypedDict
|
|
7
|
+
|
|
8
|
+
from icpc.models.entities import (
|
|
9
|
+
Eligibility,
|
|
10
|
+
Team,
|
|
11
|
+
TeamAction,
|
|
12
|
+
TeamFile,
|
|
13
|
+
TeamMember,
|
|
14
|
+
TeamViewRestrictions,
|
|
15
|
+
)
|
|
16
|
+
from icpc.models.enums import TeamStatus
|
|
17
|
+
from icpc.transport.operation import (
|
|
18
|
+
Operation,
|
|
19
|
+
Request,
|
|
20
|
+
list_op,
|
|
21
|
+
model_op,
|
|
22
|
+
none_op,
|
|
23
|
+
scalar_op,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"action",
|
|
28
|
+
"add_members",
|
|
29
|
+
"bulk_update_status",
|
|
30
|
+
"delete_file",
|
|
31
|
+
"eligibility",
|
|
32
|
+
"files",
|
|
33
|
+
"get",
|
|
34
|
+
"members",
|
|
35
|
+
"other_sites",
|
|
36
|
+
"promote",
|
|
37
|
+
"register",
|
|
38
|
+
"register_with_coach",
|
|
39
|
+
"remove_member",
|
|
40
|
+
"replace",
|
|
41
|
+
"set_coach",
|
|
42
|
+
"site_registrable",
|
|
43
|
+
"update_member",
|
|
44
|
+
"upload_file",
|
|
45
|
+
"view_restrictions",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# -------------------------------------------------------------------- read --
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get(team_id: int) -> Operation[Team]:
|
|
53
|
+
"""A team, in the exact shape :func:`replace` expects back."""
|
|
54
|
+
return model_op(Request("GET", f"/team/{team_id}"), Team)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def action(team_id: int) -> Operation[TeamAction]:
|
|
58
|
+
"""Name, extended status and payment state — what the team page header shows."""
|
|
59
|
+
return model_op(Request("GET", f"/team/{team_id}/action"), TeamAction)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def members(team_id: int) -> Operation[list[TeamMember]]:
|
|
63
|
+
"""The team's roster, with per-member certificate and attendance flags."""
|
|
64
|
+
return list_op(Request("GET", f"/team/members/team/{team_id}"), TeamMember)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def files(team_id: int) -> Operation[list[TeamFile]]:
|
|
68
|
+
"""Attachments on a team (enrollment letters, proofs of id, and so on)."""
|
|
69
|
+
return list_op(Request("GET", f"/team/file/team/{team_id}"), TeamFile)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def eligibility(team_id: int) -> Operation[Eligibility]:
|
|
73
|
+
"""The team's eligibility verdict and its verification state."""
|
|
74
|
+
return model_op(Request("GET", f"/team/eligibility/team/{team_id}"), Eligibility)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def view_restrictions(team_id: int) -> Operation[TeamViewRestrictions]:
|
|
78
|
+
"""What the current account is allowed to do to this team."""
|
|
79
|
+
return model_op(Request("GET", f"/team/{team_id}/viewrestrictions"), TeamViewRestrictions)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def other_sites(team_id: int) -> Operation[list[dict[str, object]]]:
|
|
83
|
+
"""Sites this team could move to."""
|
|
84
|
+
return list_op(Request("GET", f"/team/{team_id}/othersites"), dict[str, object])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------ writes --
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def replace(team_id: int, team: dict[str, object]) -> Operation[None]:
|
|
91
|
+
"""Overwrite a team with ``team``.
|
|
92
|
+
|
|
93
|
+
**Destructive.** This is a full-object replace, not a partial update: the server
|
|
94
|
+
recomputes the team's eligibility and clears any verified statuses, exactly as
|
|
95
|
+
the web UI warns before saving. Send back a complete object obtained from
|
|
96
|
+
:func:`get` with only the intended fields changed — the facade's
|
|
97
|
+
``update_team`` does that read-modify-write for you.
|
|
98
|
+
"""
|
|
99
|
+
return none_op(
|
|
100
|
+
Request(
|
|
101
|
+
"POST",
|
|
102
|
+
f"/team/{team_id}",
|
|
103
|
+
json=team,
|
|
104
|
+
idempotent=False,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def bulk_update_status(
|
|
110
|
+
contest_id: int, team_ids: Sequence[int], new_status: TeamStatus | str
|
|
111
|
+
) -> Operation[None]:
|
|
112
|
+
"""Accept, reject or reset many teams of one contest at once."""
|
|
113
|
+
return none_op(
|
|
114
|
+
Request(
|
|
115
|
+
"PUT",
|
|
116
|
+
f"/team/bulkupdate/contest/{contest_id}",
|
|
117
|
+
json={"newStatus": str(new_status), "teamIds": list(team_ids)},
|
|
118
|
+
idempotent=False,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def promote(team_id: int, site_id: int) -> Operation[None]:
|
|
124
|
+
"""Promote a team to a site of the parent contest.
|
|
125
|
+
|
|
126
|
+
Refusals come back two ways. A team that is already promoted answers HTTP
|
|
127
|
+
500 with a body saying so, which the transport turns into
|
|
128
|
+
:class:`~icpc.errors.TeamNotPromotable` rather than a generic server error;
|
|
129
|
+
a conflict with the target site answers a plain 400, "The team cannot be
|
|
130
|
+
promoted, please check the target site for conflicts".
|
|
131
|
+
"""
|
|
132
|
+
return none_op(
|
|
133
|
+
Request(
|
|
134
|
+
"POST",
|
|
135
|
+
f"/team/{team_id}/promote/{site_id}",
|
|
136
|
+
idempotent=False,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def upload_file(
|
|
142
|
+
team_id: int, filename: str, content: bytes, mime: str = "application/pdf"
|
|
143
|
+
) -> Operation[None]:
|
|
144
|
+
"""Attach a file to a team.
|
|
145
|
+
|
|
146
|
+
The server renames the upload, inserting a long random number before the
|
|
147
|
+
extension — ``INVITATION-.pdf`` comes back as
|
|
148
|
+
``INVITATION-9776387880749677008.pdf`` — so match on prefix and suffix when
|
|
149
|
+
looking it up again.
|
|
150
|
+
"""
|
|
151
|
+
return none_op(
|
|
152
|
+
Request(
|
|
153
|
+
"POST",
|
|
154
|
+
f"/team/file/{team_id}",
|
|
155
|
+
files={"file": (filename, content, mime)},
|
|
156
|
+
idempotent=False,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def delete_file(file_id: int) -> Operation[None]:
|
|
162
|
+
"""Remove one attachment, by *file* id — not team id."""
|
|
163
|
+
return none_op(
|
|
164
|
+
Request(
|
|
165
|
+
"DELETE",
|
|
166
|
+
f"/team/file/{file_id}",
|
|
167
|
+
idempotent=False,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class NewTeamMember(TypedDict, total=False):
|
|
173
|
+
"""One member of a team being registered."""
|
|
174
|
+
|
|
175
|
+
#: ``CONTESTANT``, ``COACH``, ``COCOACH`` or ``CONTESTANT_COACH``.
|
|
176
|
+
role: str
|
|
177
|
+
#: A *person id* — a bare number, not an object. Resolve one with
|
|
178
|
+
#: :func:`icpc.api.person.suggest`.
|
|
179
|
+
person: int
|
|
180
|
+
badgeRole: str | None
|
|
181
|
+
certificateRole: str | None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class NewTeam(TypedDict, total=False):
|
|
185
|
+
"""One team to register."""
|
|
186
|
+
|
|
187
|
+
name: str
|
|
188
|
+
siteId: int
|
|
189
|
+
#: From :func:`icpc.api.common.institution_suggest` — *not* the ``instId`` or
|
|
190
|
+
#: ``instUnitId`` of the institution search grid.
|
|
191
|
+
institutionUnitId: int
|
|
192
|
+
studentCoach: bool
|
|
193
|
+
teamMembers: list[NewTeamMember]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def register(teams: Sequence[NewTeam]) -> Operation[dict[str, str]]:
|
|
197
|
+
"""Register one or more teams, returning ``{new team id: name}``.
|
|
198
|
+
|
|
199
|
+
This is ``/team/register/bulk``, which is what the registration wizard
|
|
200
|
+
actually calls; the singular ``/team/register`` is defined in the frontend
|
|
201
|
+
bundle but never used by it, and rejects everything this sends.
|
|
202
|
+
|
|
203
|
+
Two things the server does that are not obvious:
|
|
204
|
+
|
|
205
|
+
* **The registering account is added as a coach automatically.** Listing
|
|
206
|
+
yourself again gives "Person … is twice in team", and listing another coach
|
|
207
|
+
as well can exceed the contest's coach limit.
|
|
208
|
+
* The whole batch is validated before anything is written, so a rejected
|
|
209
|
+
batch leaves no partial team behind.
|
|
210
|
+
|
|
211
|
+
The UI registers at most ten teams at a time and checks
|
|
212
|
+
``GET /team/site/{siteId}/registrable`` first.
|
|
213
|
+
"""
|
|
214
|
+
return Operation(
|
|
215
|
+
Request("POST", "/team/register/bulk", json=list(teams), idempotent=False),
|
|
216
|
+
lambda r: dict(r.json()) if r.content else {},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def site_registrable(site_id: int) -> Operation[bool]:
|
|
221
|
+
"""Whether a site is currently open for team registration."""
|
|
222
|
+
return scalar_op(Request("GET", f"/team/site/{site_id}/registrable"), bool)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class NewMember(TypedDict, total=False):
|
|
226
|
+
"""A member being added to an existing team.
|
|
227
|
+
|
|
228
|
+
Note this is not shaped like :class:`NewTeamMember`: here ``person`` is an
|
|
229
|
+
object and the role field is ``role`` (the ``TeamMemberRegistrationDto``),
|
|
230
|
+
whereas bulk registration wants a bare person id.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
person: dict[str, int]
|
|
234
|
+
#: ``CONTESTANT``, ``COACH``, ``COCOACH``, ``CONTESTANT_COACH``,
|
|
235
|
+
#: ``ATTENDEE``, ``RESERVE`` or ``STAFF``.
|
|
236
|
+
role: str
|
|
237
|
+
badgeRole: str
|
|
238
|
+
certificateRole: str
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def register_with_coach(team: NewTeam, coach_id: int | None = None) -> Operation[int]:
|
|
242
|
+
"""Register a single team **without** making yourself its coach.
|
|
243
|
+
|
|
244
|
+
``/team/register/bulk`` always adds the registering account as a coach, so
|
|
245
|
+
with the usual limit of one coach per team there is no room for anybody
|
|
246
|
+
else. This endpoint does not, which is what it is for.
|
|
247
|
+
|
|
248
|
+
It takes a single object, not an array, and returns the new team id as a
|
|
249
|
+
bare number. ``coach_id`` is accepted for symmetry but the server ignores
|
|
250
|
+
it: the team is created with no coach at all, and :func:`set_coach` is how
|
|
251
|
+
you attach one.
|
|
252
|
+
"""
|
|
253
|
+
body = dict(team)
|
|
254
|
+
if coach_id is not None:
|
|
255
|
+
body["coachId"] = coach_id # type: ignore[typeddict-unknown-key]
|
|
256
|
+
return scalar_op(
|
|
257
|
+
Request("POST", "/team/register/customcoach", json=body, idempotent=False), int
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def set_coach(
|
|
262
|
+
team_id: int,
|
|
263
|
+
person_id: int,
|
|
264
|
+
*,
|
|
265
|
+
role: str = "COACH",
|
|
266
|
+
badge_role: str | None = None,
|
|
267
|
+
certificate_role: str | None = None,
|
|
268
|
+
) -> Operation[TeamMember]:
|
|
269
|
+
"""Make someone the team's coach, or its ``CONTESTANT_COACH``.
|
|
270
|
+
|
|
271
|
+
This endpoint is not the same as :func:`add_members` with a coaching role,
|
|
272
|
+
and the difference matters:
|
|
273
|
+
|
|
274
|
+
* **It fills the coach slot rather than adding to it.** An incumbent
|
|
275
|
+
``CONTESTANT_COACH`` is demoted to ``CONTESTANT`` in place — same member
|
|
276
|
+
id, and ``registrationComplete``, attendance and the certificate flags
|
|
277
|
+
all survive, unlike the remove-and-re-add that a role change otherwise
|
|
278
|
+
costs.
|
|
279
|
+
* **It ignores ``maxCoaches``.** The same swap through ``/add`` answers
|
|
280
|
+
"2 coaches exceeds the coach limit of 1"; this path just goes through.
|
|
281
|
+
|
|
282
|
+
The person must not already be on the team ("Person … is twice in team"),
|
|
283
|
+
and a person who is already a *contestant* in the same contest cannot be a
|
|
284
|
+
coach there ("Person … can't be coach").
|
|
285
|
+
|
|
286
|
+
``role`` may be ``CONTESTANT_COACH``, which the server accepts here and
|
|
287
|
+
which is the cheapest way to install one. It then validates the contestant
|
|
288
|
+
half too, so the person must not be a contestant on another team in the
|
|
289
|
+
contest ("Contestant … can participate only in one team").
|
|
290
|
+
|
|
291
|
+
``badge_role`` and ``certificate_role`` default to ``role`` in title case —
|
|
292
|
+
"Coach", "Contestant Coach".
|
|
293
|
+
"""
|
|
294
|
+
label = badge_role or role.replace("_", " ").title()
|
|
295
|
+
return model_op(
|
|
296
|
+
Request(
|
|
297
|
+
"POST",
|
|
298
|
+
f"/team/members/team/{team_id}/coach",
|
|
299
|
+
json={
|
|
300
|
+
"person": {"id": person_id},
|
|
301
|
+
"role": role,
|
|
302
|
+
"badgeRole": label,
|
|
303
|
+
"certificateRole": certificate_role or label,
|
|
304
|
+
},
|
|
305
|
+
idempotent=False,
|
|
306
|
+
),
|
|
307
|
+
TeamMember,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def add_members(team_id: int, members: Sequence[NewMember]) -> Operation[list[TeamMember]]:
|
|
312
|
+
"""Add people to an existing team. The body is an array, even for one member."""
|
|
313
|
+
return list_op(
|
|
314
|
+
Request("POST", f"/team/members/team/{team_id}/add", json=list(members), idempotent=False),
|
|
315
|
+
TeamMember,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def remove_member(member_id: int) -> Operation[None]:
|
|
320
|
+
"""Remove a member, by *member* id — not person id."""
|
|
321
|
+
return none_op(Request("DELETE", f"/team/members/{member_id}", idempotent=False))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def update_member(member_id: int, member: dict[str, object]) -> Operation[TeamMember]:
|
|
325
|
+
"""Overwrite a member with ``member``, a full object from :func:`members`.
|
|
326
|
+
|
|
327
|
+
A full-object replace, like the team write. The web UI uses this only for
|
|
328
|
+
``attendingOnsite`` and the two certificate flags; changing ``role`` through
|
|
329
|
+
it answers 500, so change a role by removing the member and adding them back
|
|
330
|
+
with :func:`remove_member` and :func:`add_members`.
|
|
331
|
+
"""
|
|
332
|
+
return model_op(
|
|
333
|
+
Request("POST", f"/team/members/{member_id}", json=member, idempotent=False),
|
|
334
|
+
TeamMember,
|
|
335
|
+
)
|
icpc/auth/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Cognito authentication: SRP password login, refresh, and token storage."""
|
|
2
|
+
|
|
3
|
+
from icpc.auth.cognito import Challenge
|
|
4
|
+
from icpc.auth.flows import (
|
|
5
|
+
AsyncCognitoAuth,
|
|
6
|
+
CognitoAuth,
|
|
7
|
+
StaticTokenAuth,
|
|
8
|
+
SyncStaticTokenAuth,
|
|
9
|
+
)
|
|
10
|
+
from icpc.auth.provider import AsyncTokenProvider, TokenProvider
|
|
11
|
+
from icpc.auth.srp import SrpSession
|
|
12
|
+
from icpc.auth.store import Account, CredentialStore, default_config_dir
|
|
13
|
+
from icpc.auth.tokens import TokenSet
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Account",
|
|
17
|
+
"AsyncCognitoAuth",
|
|
18
|
+
"AsyncTokenProvider",
|
|
19
|
+
"Challenge",
|
|
20
|
+
"CognitoAuth",
|
|
21
|
+
"CredentialStore",
|
|
22
|
+
"SrpSession",
|
|
23
|
+
"StaticTokenAuth",
|
|
24
|
+
"SyncStaticTokenAuth",
|
|
25
|
+
"TokenProvider",
|
|
26
|
+
"TokenSet",
|
|
27
|
+
"default_config_dir",
|
|
28
|
+
]
|
icpc/auth/cognito.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Cognito RPCs over plain HTTP — no boto3, no AWS credentials.
|
|
2
|
+
|
|
3
|
+
``InitiateAuth`` and ``RespondToAuthChallenge`` are unsigned operations on a public
|
|
4
|
+
app client, so they are ordinary JSON-1.1 POSTs. This module holds the pure request
|
|
5
|
+
builders and response parsing; :mod:`icpc.auth.flows` drives them over httpx.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from icpc import errors
|
|
14
|
+
from icpc.auth.srp import SrpSession
|
|
15
|
+
from icpc.auth.tokens import TokenSet
|
|
16
|
+
from icpc.config import Settings
|
|
17
|
+
|
|
18
|
+
__all__ = ["Challenge", "CognitoCall", "Outcome", "parse_outcome"]
|
|
19
|
+
|
|
20
|
+
_TARGET = "AWSCognitoIdentityProviderService"
|
|
21
|
+
|
|
22
|
+
PASSWORD_VERIFIER = "PASSWORD_VERIFIER"
|
|
23
|
+
SOFTWARE_TOKEN_MFA = "SOFTWARE_TOKEN_MFA"
|
|
24
|
+
SMS_MFA = "SMS_MFA"
|
|
25
|
+
NEW_PASSWORD_REQUIRED = "NEW_PASSWORD_REQUIRED"
|
|
26
|
+
|
|
27
|
+
#: First HTTP status Cognito uses to signal a failure.
|
|
28
|
+
_HTTP_ERROR = 400
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class CognitoCall:
|
|
33
|
+
"""A single JSON-1.1 RPC: where to send it and what to send."""
|
|
34
|
+
|
|
35
|
+
target: str
|
|
36
|
+
payload: dict[str, Any]
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def headers(self) -> dict[str, str]:
|
|
40
|
+
return {
|
|
41
|
+
"Content-Type": "application/x-amz-json-1.1",
|
|
42
|
+
"X-Amz-Target": f"{_TARGET}.{self.target}",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class Challenge:
|
|
48
|
+
"""Cognito wants another round trip before it will issue tokens."""
|
|
49
|
+
|
|
50
|
+
name: str
|
|
51
|
+
session: str
|
|
52
|
+
parameters: dict[str, str]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
#: Either we got tokens, or we owe Cognito another answer.
|
|
56
|
+
type Outcome = TokenSet | Challenge
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def initiate_srp(settings: Settings, srp: SrpSession) -> CognitoCall:
|
|
60
|
+
"""Step 1 of the password login.
|
|
61
|
+
|
|
62
|
+
icpc.global's pool uses ``CUSTOM_AUTH`` with ``CHALLENGE_NAME: SRP_A`` rather
|
|
63
|
+
than the more usual ``USER_SRP_AUTH``; it answers with ``PASSWORD_VERIFIER``.
|
|
64
|
+
"""
|
|
65
|
+
return CognitoCall(
|
|
66
|
+
"InitiateAuth",
|
|
67
|
+
{
|
|
68
|
+
"AuthFlow": "CUSTOM_AUTH",
|
|
69
|
+
"ClientId": settings.client_id,
|
|
70
|
+
"AuthParameters": srp.auth_parameters(),
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def respond_password_verifier(
|
|
76
|
+
settings: Settings, responses: dict[str, str], session: str
|
|
77
|
+
) -> CognitoCall:
|
|
78
|
+
"""Step 2: the SRP proof. ``Session`` must be echoed back for ``CUSTOM_AUTH``."""
|
|
79
|
+
return CognitoCall(
|
|
80
|
+
"RespondToAuthChallenge",
|
|
81
|
+
{
|
|
82
|
+
"ClientId": settings.client_id,
|
|
83
|
+
"ChallengeName": PASSWORD_VERIFIER,
|
|
84
|
+
"ChallengeResponses": responses,
|
|
85
|
+
"Session": session,
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def respond_mfa(settings: Settings, challenge: Challenge, username: str, code: str) -> CognitoCall:
|
|
91
|
+
"""Answer a software-token or SMS MFA challenge."""
|
|
92
|
+
key = "SOFTWARE_TOKEN_MFA_CODE" if challenge.name == SOFTWARE_TOKEN_MFA else "SMS_MFA_CODE"
|
|
93
|
+
return CognitoCall(
|
|
94
|
+
"RespondToAuthChallenge",
|
|
95
|
+
{
|
|
96
|
+
"ClientId": settings.client_id,
|
|
97
|
+
"ChallengeName": challenge.name,
|
|
98
|
+
"ChallengeResponses": {"USERNAME": username, key: code},
|
|
99
|
+
"Session": challenge.session,
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def refresh(settings: Settings, refresh_token: str) -> CognitoCall:
|
|
105
|
+
"""Renew the id token. Needs no password and no AWS credentials."""
|
|
106
|
+
return CognitoCall(
|
|
107
|
+
"InitiateAuth",
|
|
108
|
+
{
|
|
109
|
+
"AuthFlow": "REFRESH_TOKEN_AUTH",
|
|
110
|
+
"ClientId": settings.client_id,
|
|
111
|
+
"AuthParameters": {"REFRESH_TOKEN": refresh_token},
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_outcome(payload: dict[str, Any], *, refresh_token: str | None = None) -> Outcome:
|
|
117
|
+
"""Turn a Cognito response into tokens or the next challenge."""
|
|
118
|
+
result = payload.get("AuthenticationResult")
|
|
119
|
+
if result:
|
|
120
|
+
return TokenSet.from_cognito(result, refresh_token=refresh_token)
|
|
121
|
+
|
|
122
|
+
name = payload.get("ChallengeName")
|
|
123
|
+
if name is None:
|
|
124
|
+
raise errors.CognitoError("UnexpectedResponse", f"no tokens and no challenge: {payload}")
|
|
125
|
+
if name == NEW_PASSWORD_REQUIRED:
|
|
126
|
+
raise errors.AuthError("Cognito requires a password change before this account can log in")
|
|
127
|
+
return Challenge(
|
|
128
|
+
name=name,
|
|
129
|
+
session=payload.get("Session", ""),
|
|
130
|
+
parameters=payload.get("ChallengeParameters", {}),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def raise_for_error(status: int, payload: dict[str, Any]) -> None:
|
|
135
|
+
"""Map a Cognito error body onto :mod:`icpc.errors`."""
|
|
136
|
+
if status < _HTTP_ERROR:
|
|
137
|
+
return
|
|
138
|
+
code = str(payload.get("__type", "CognitoError")).rsplit("#", 1)[-1]
|
|
139
|
+
message = str(payload.get("message", payload))
|
|
140
|
+
if code in {"NotAuthorizedException", "UserNotFoundException"}:
|
|
141
|
+
raise errors.InvalidCredentials(f"{code}: {message}")
|
|
142
|
+
raise errors.CognitoError(code, message)
|