auth-guide 0.5.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.
auth_guide/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Local account and bearer-token service package.
2
+
3
+ Runtime interfaces are deliberately deferred until the storage feasibility
4
+ work selects the persistence approach.
5
+ """
auth_guide/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Module entry point for detached auth-guide server processes."""
2
+
3
+ from auth_guide.cli import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
@@ -0,0 +1,322 @@
1
+ """Encrypted asynchronous provider account persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import secrets
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from anyio import Path as AsyncPath
12
+ from tortoise import Tortoise
13
+ from tortoise.connection import get_connection
14
+ from tortoise.migrations.api import migrate
15
+ from tortoise.migrations.executor import MigrationExecutor
16
+
17
+ from auth_guide.accounts.models import AccountRecord, AccountTokenRecord, Grant
18
+ from auth_guide.accounts.passwords import PasswordOperations
19
+ from auth_guide.accounts.tokens import (
20
+ NewAccountToken,
21
+ generate_account_token,
22
+ resolve_account_token,
23
+ verify_account_token,
24
+ )
25
+ from auth_guide.secrets import (
26
+ MissingSecretError,
27
+ PlatformSecretProvider,
28
+ SecretAlreadyExistsError,
29
+ SecretReference,
30
+ )
31
+
32
+ DATABASE_KEY_REFERENCE = SecretReference("auth-guide-database-key")
33
+ DATABASE_FILENAME = "auth-guide.sqlite3"
34
+
35
+ __all__ = [
36
+ "AccountRecord",
37
+ "AccountTokenRecord",
38
+ "AccountStore",
39
+ "AccountStoreError",
40
+ "DATABASE_FILENAME",
41
+ "Grant",
42
+ "NewAccountToken",
43
+ "generate_account_token",
44
+ "resolve_account_token",
45
+ "verify_account_token",
46
+ ]
47
+
48
+
49
+ class AccountStoreError(RuntimeError):
50
+ """Base error for account-store lifecycle and persistence failures."""
51
+
52
+
53
+ class AccountStore:
54
+ """Own the encrypted Tortoise connection and account-password operations."""
55
+
56
+ def __init__(self, config_dir: str | Path | AsyncPath, secrets_provider: PlatformSecretProvider) -> None:
57
+ self._config_dir = AsyncPath(config_dir)
58
+ self._secrets = secrets_provider
59
+ self._passwords = PasswordOperations()
60
+ self._database_key: bytes | None = None
61
+ self._started = False
62
+ self._migrated = False
63
+ self._initialised_here = False
64
+
65
+ async def __aenter__(self) -> "AccountStore":
66
+ await self.start()
67
+ return self
68
+
69
+ async def __aexit__(self, *_: object) -> None:
70
+ await self.aclose()
71
+
72
+ async def start(self) -> None:
73
+ """Configure encrypted storage without applying database migrations."""
74
+ if self._started:
75
+ return
76
+ await self._config_dir.mkdir(parents=True, exist_ok=True)
77
+ self._database_key = await self._resolve_database_key()
78
+ await self._passwords.start()
79
+ try:
80
+ await Tortoise.init(
81
+ config=self._tortoise_config(),
82
+ init_connections=False,
83
+ _enable_global_fallback=True,
84
+ )
85
+ self._started = True
86
+ except BaseException:
87
+ await self._passwords.aclose()
88
+ self._database_key = None
89
+ raise
90
+
91
+ async def initialise(self) -> None:
92
+ """Explicitly create a new encrypted account database from user migrations."""
93
+ if self._started:
94
+ raise AccountStoreError("Account store is already started")
95
+ await self._config_dir.mkdir(parents=True, exist_ok=True)
96
+ if await self._database_path.exists():
97
+ raise AccountStoreError("Account database already exists")
98
+ if not await self._has_user_migrations():
99
+ raise AccountStoreError("Account store migration is required")
100
+ key = secrets.token_bytes(32)
101
+ try:
102
+ creation_cancelled = await self._create_database_key(key)
103
+ except SecretAlreadyExistsError as error:
104
+ raise AccountStoreError("Account database key already exists") from error
105
+ try:
106
+ created_database_key = True
107
+ if creation_cancelled:
108
+ raise asyncio.CancelledError
109
+ self._database_key = key
110
+ await self._passwords.start()
111
+ await Tortoise.init(config=self._tortoise_config(), init_connections=False)
112
+ self._started = True
113
+ await migrate(config=self._tortoise_config())
114
+ await Tortoise.init(config=self._tortoise_config(), _enable_global_fallback=True)
115
+ self._migrated = True
116
+ self._initialised_here = True
117
+ except BaseException:
118
+ await self._cleanup_failed_initialisation(created_database_key)
119
+ raise
120
+
121
+ async def remove_orphan_database_key(self) -> None:
122
+ """Remove a present database key only when no database exists."""
123
+ if await self._database_path.exists():
124
+ raise AccountStoreError("Account database already exists")
125
+ if self._started:
126
+ await self.aclose()
127
+ self._database_key = None
128
+ self._passwords = PasswordOperations()
129
+ try:
130
+ await self._secrets.resolve(DATABASE_KEY_REFERENCE)
131
+ except MissingSecretError as error:
132
+ raise AccountStoreError("Account database key does not exist") from error
133
+ await self._secrets.delete(DATABASE_KEY_REFERENCE)
134
+
135
+ async def rollback_initialisation(self) -> None:
136
+ """Remove only database state created by this store's initialisation."""
137
+ if not self._initialised_here:
138
+ raise AccountStoreError("Account store has no initialisation to roll back")
139
+ await self._cleanup_failed_initialisation(created_database_key=True)
140
+
141
+ async def aclose(self) -> None:
142
+ """Close database connections owned by this store."""
143
+ if self._started:
144
+ await Tortoise.close_connections()
145
+ await self._passwords.aclose()
146
+ self._started = False
147
+ self._migrated = False
148
+
149
+ async def apply_migrations(self) -> None:
150
+ """Explicitly apply the deploying user's native Tortoise migrations."""
151
+ await self._require_started()
152
+ if not await self._database_path.exists():
153
+ raise AccountStoreError("Account database is not initialised")
154
+ if not await self._has_user_migrations():
155
+ raise AccountStoreError("Account store migration is required")
156
+ await migrate(config=self._tortoise_config())
157
+ await Tortoise.init(config=self._tortoise_config())
158
+ self._migrated = True
159
+
160
+ async def migration_required(self) -> bool:
161
+ """Report whether native migrations are pending without applying them."""
162
+ await self.pending_migration_plan()
163
+ return not self._migrated
164
+
165
+ async def pending_migration_plan(self) -> tuple[str, ...]:
166
+ """Return a silent, display-ready plan of unapplied native migrations."""
167
+ await self._require_started()
168
+ if not await self._has_user_migrations():
169
+ self._migrated = False
170
+ return ()
171
+ if not await self._database_path.exists():
172
+ self._migrated = False
173
+ return ()
174
+ config = self._tortoise_config()
175
+ connection = get_connection("default")
176
+ await connection.create_connection(with_db=True)
177
+ executor = MigrationExecutor(connection, config["apps"])
178
+ steps = await executor.plan()
179
+ migration_plan = tuple(
180
+ f"{'-' if step.backward else '+'} {step.migration.app_label}.{step.migration.name}" for step in steps
181
+ )
182
+ self._migrated = not migration_plan
183
+ return migration_plan
184
+
185
+ async def list_accounts(self, *, include_inactive: bool = False) -> list[AccountRecord]:
186
+ """Return complete account records, excluding inactive accounts by default."""
187
+ await self._require_migrated()
188
+ accounts = AccountRecord.including_inactive() if include_inactive else AccountRecord.all()
189
+ return await accounts.order_by("email_comparison")
190
+
191
+ async def set_password(self, account: AccountRecord, password: str) -> None:
192
+ """Persist a fixed-policy Argon2id verifier on an account record."""
193
+ await self._require_migrated()
194
+ account.password_hash = await self._passwords.hash(password)
195
+ account.must_change_password = False
196
+ await account.save(update_fields=["password_hash", "must_change_password"])
197
+
198
+ async def create_account(self, account: AccountRecord, password: str) -> None:
199
+ """Persist a newly provisioned account and its initial password together."""
200
+ await self._require_migrated()
201
+ account.password_hash = await self._passwords.hash(password)
202
+ account.must_change_password = True
203
+ await account.save()
204
+
205
+ async def reset_password(self, account: AccountRecord, password: str) -> None:
206
+ """Replace a managed account password and require its next change."""
207
+ await self._require_migrated()
208
+ account.password_hash = await self._passwords.hash(password)
209
+ account.must_change_password = True
210
+ await account.save(update_fields=["password_hash", "must_change_password"])
211
+
212
+ async def verify_password(self, account: AccountRecord | None, password: str) -> bool:
213
+ """Verify an active account password against its fixed Argon2id policy."""
214
+ await self._require_migrated()
215
+ if account is None or account.password_hash is None:
216
+ await self._passwords.verify_unknown(password)
217
+ return False
218
+ if not account.is_active:
219
+ await self._passwords.verify_unknown(password)
220
+ return False
221
+ if not await self._passwords.verify(account.password_hash, password):
222
+ return False
223
+ current_account = await AccountRecord.get_or_none(id=account.id)
224
+ return (
225
+ current_account is not None
226
+ and current_account.is_active
227
+ and current_account.password_hash == account.password_hash
228
+ )
229
+
230
+ async def _cleanup_failed_initialisation(self, created_database_key: bool) -> None:
231
+ """Close partial state and remove a key created by this failed invocation."""
232
+ try:
233
+ if self._started:
234
+ await self.aclose()
235
+ else:
236
+ await self._passwords.aclose()
237
+ finally:
238
+ self._started = False
239
+ self._migrated = False
240
+ self._initialised_here = False
241
+ self._database_key = None
242
+ for database_path in self._database_files:
243
+ if await database_path.exists():
244
+ await database_path.unlink()
245
+ if created_database_key:
246
+ await self._secrets.delete(DATABASE_KEY_REFERENCE)
247
+
248
+ async def _create_database_key(self, key: bytes) -> bool:
249
+ """Create a key to completion and report whether the caller cancelled."""
250
+ creation = asyncio.create_task(
251
+ self._secrets.create(DATABASE_KEY_REFERENCE, base64.b64encode(key).decode("ascii"))
252
+ )
253
+ cancelled = False
254
+ while not creation.done():
255
+ try:
256
+ await asyncio.shield(creation)
257
+ except asyncio.CancelledError:
258
+ cancelled = True
259
+ await creation
260
+ return cancelled
261
+
262
+ async def _resolve_database_key(self) -> bytes:
263
+ encoded_key = await self._secrets.resolve(DATABASE_KEY_REFERENCE)
264
+ try:
265
+ key = base64.b64decode(encoded_key, validate=True)
266
+ except ValueError as error:
267
+ raise AccountStoreError("Stored database key is invalid") from error
268
+ if len(key) != 32:
269
+ raise AccountStoreError("Stored database key is invalid")
270
+ return key
271
+
272
+ async def _require_started(self) -> None:
273
+ if not self._started:
274
+ raise AccountStoreError("Account store is not started")
275
+
276
+ async def _require_migrated(self) -> None:
277
+ await self._require_started()
278
+ if not self._migrated:
279
+ raise AccountStoreError("Account store migration is required")
280
+
281
+ async def _has_user_migrations(self) -> bool:
282
+ """Report whether the deploying user has supplied a native migration."""
283
+ migrations_path = AsyncPath(__file__).parent / "migrations"
284
+ async for path in migrations_path.iterdir():
285
+ if path.suffix == ".py" and path.name != "__init__.py" and not path.name.startswith("_"):
286
+ return True
287
+ return False
288
+
289
+ @property
290
+ def _database_path(self) -> AsyncPath:
291
+ """Return the configured encrypted database path."""
292
+ return self._config_dir / DATABASE_FILENAME
293
+
294
+ @property
295
+ def _database_files(self) -> tuple[AsyncPath, AsyncPath, AsyncPath]:
296
+ """Return the database file and SQLite write-ahead-log sidecars."""
297
+ database_path = self._database_path
298
+ return database_path, AsyncPath(f"{database_path}-wal"), AsyncPath(f"{database_path}-shm")
299
+
300
+ def _tortoise_config(self) -> dict[str, Any]:
301
+ key = self._database_key
302
+ if key is None:
303
+ raise AccountStoreError("Account store database key is unavailable")
304
+ return {
305
+ "use_tz": True,
306
+ "timezone": "UTC",
307
+ "connections": {
308
+ "default": {
309
+ "engine": "tortoise_sqlcipher.sqlite_sqlcipher",
310
+ "credentials": {
311
+ "file_path": str(self._database_path),
312
+ "encryption_key": key,
313
+ },
314
+ }
315
+ },
316
+ "apps": {
317
+ "accounts": {
318
+ "models": ["auth_guide.accounts.models"],
319
+ "migrations": "auth_guide.accounts.migrations",
320
+ }
321
+ },
322
+ }
@@ -0,0 +1,63 @@
1
+ import functools
2
+ from json import dumps, loads
3
+ from uuid import uuid4
4
+
5
+ from tortoise import fields, migrations
6
+ from tortoise.fields.base import OnDelete
7
+ from tortoise.migrations import operations as ops
8
+
9
+
10
+ class Migration(migrations.Migration):
11
+ initial = True
12
+
13
+ operations = [
14
+ ops.CreateModel(
15
+ name="AccountRecord",
16
+ fields=[
17
+ ("id", fields.UUIDField(primary_key=True, default=uuid4, unique=True, db_index=True)),
18
+ ("email", fields.CharField(max_length=254)),
19
+ ("email_comparison", fields.CharField(unique=True, max_length=254)),
20
+ ("grants", fields.JSONField(encoder=functools.partial(dumps, separators=(",", ":")), decoder=loads)),
21
+ ("password_hash", fields.TextField(unique=False)),
22
+ ("created_at", fields.DatetimeField(auto_now=False, auto_now_add=True)),
23
+ ("expires_at", fields.DatetimeField(null=True, auto_now=False, auto_now_add=False)),
24
+ ("full_name", fields.TextField(null=True, unique=False)),
25
+ ("must_change_password", fields.BooleanField(default=False)),
26
+ ],
27
+ options={
28
+ "table": "accounts",
29
+ "app": "accounts",
30
+ "pk_attr": "id",
31
+ "table_description": "The persisted representation of one provider account.",
32
+ },
33
+ bases=["Model"],
34
+ ),
35
+ ops.CreateModel(
36
+ name="AccountTokenRecord",
37
+ fields=[
38
+ ("id", fields.UUIDField(primary_key=True, default=uuid4, unique=True, db_index=True)),
39
+ (
40
+ "account",
41
+ fields.ForeignKeyField(
42
+ "accounts.AccountRecord",
43
+ source_field="account_id",
44
+ db_constraint=True,
45
+ to_field="id",
46
+ related_name="tokens",
47
+ on_delete=OnDelete.CASCADE,
48
+ ),
49
+ ),
50
+ ("label", fields.TextField(unique=False)),
51
+ ("verifier", fields.CharField(unique=True, max_length=64)),
52
+ ("scopes", fields.JSONField(encoder=functools.partial(dumps, separators=(",", ":")), decoder=loads)),
53
+ ("created_at", fields.DatetimeField(auto_now=False, auto_now_add=True)),
54
+ ],
55
+ options={
56
+ "table": "account_tokens",
57
+ "app": "accounts",
58
+ "pk_attr": "id",
59
+ "table_description": "A revocable account token represented only by its verifier and metadata.",
60
+ },
61
+ bases=["Model"],
62
+ ),
63
+ ]
@@ -0,0 +1 @@
1
+ """Native Tortoise migrations for account persistence."""
@@ -0,0 +1,206 @@
1
+ """Tortoise models for encrypted account persistence."""
2
+
3
+ import logging
4
+ from collections.abc import Iterable
5
+ from datetime import datetime, timezone
6
+ from enum import StrEnum
7
+ from typing import Any
8
+
9
+ from email_validator import EmailNotValidError, validate_email
10
+ from tortoise import fields
11
+ from tortoise.backends.base.client import BaseDBAsyncClient
12
+ from tortoise.expressions import Q
13
+ from tortoise.manager import Manager
14
+ from tortoise.models import Model
15
+ from tortoise.queryset import QuerySet
16
+
17
+ from auth_guide.accounts.passwords import PasswordOperations
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class Grant(StrEnum):
23
+ """Recognised capability grants persisted for provider accounts."""
24
+
25
+ GUIDE_ADMIN = "guide:admin"
26
+ ACCOUNTS_READ = "accounts:read"
27
+ ACCOUNTS_MANAGE = "accounts:manage"
28
+ TOKENS_MANAGE = "tokens:manage"
29
+
30
+
31
+ class AccountQuerySet(QuerySet):
32
+ """Warn when native bulk writes bypass account-model validation."""
33
+
34
+ def update(self, **kwargs: object):
35
+ logger.warning("Bulk account update bypasses AccountRecord.save validation")
36
+ return super().update(**kwargs)
37
+
38
+ def bulk_create(
39
+ self,
40
+ objects: Iterable[Any],
41
+ batch_size: int | None = None,
42
+ ignore_conflicts: bool = False,
43
+ update_fields: Iterable[str] | None = None,
44
+ on_conflict: Iterable[str] | None = None,
45
+ ) -> Any:
46
+ logger.warning("Bulk account creation bypasses AccountRecord.save validation")
47
+ return super().bulk_create(objects, batch_size, ignore_conflicts, update_fields, on_conflict)
48
+
49
+ def bulk_update(self, objects: Iterable[Any], fields: Iterable[str], batch_size: int | None = None) -> Any:
50
+ logger.warning("Bulk account update bypasses AccountRecord.save validation")
51
+ return super().bulk_update(objects, fields, batch_size)
52
+
53
+
54
+ class ActiveAccountManager(Manager):
55
+ """Provide active accounts through the normal model query interface."""
56
+
57
+ def get_queryset(self) -> QuerySet:
58
+ """Return accounts whose optional expiry permits access now."""
59
+ if self._model is None:
60
+ raise RuntimeError("Account manager has no model")
61
+ return AccountQuerySet(self._model).filter(Q(expires_at=None) | Q(expires_at__gt=datetime.now(timezone.utc)))
62
+
63
+
64
+ class AccountRecord(Model):
65
+ """The persisted representation of one provider account."""
66
+
67
+ id = fields.UUIDField(primary_key=True)
68
+ email = fields.CharField(max_length=254)
69
+ email_comparison = fields.CharField(max_length=254, unique=True)
70
+ grants = fields.JSONField()
71
+ password_hash = fields.TextField()
72
+ created_at = fields.DatetimeField(auto_now_add=True)
73
+ expires_at = fields.DatetimeField(null=True)
74
+ full_name = fields.TextField(null=True)
75
+ must_change_password = fields.BooleanField(default=False)
76
+
77
+ class Meta: # ty: ignore[invalid-attribute-override]
78
+ table = "accounts"
79
+ manager = ActiveAccountManager()
80
+
81
+ @classmethod
82
+ def including_inactive(cls) -> QuerySet:
83
+ """Return an unfiltered account query for explicit administration work."""
84
+ return AccountQuerySet(cls)
85
+
86
+ @property
87
+ def is_active(self) -> bool:
88
+ """Report whether the account's optional expiry permits access."""
89
+ return self.expires_at is None or self.expires_at > datetime.now(timezone.utc)
90
+
91
+ def has_grant(self, requested_grant: Grant) -> bool:
92
+ """Report whether an active account holds the requested grant."""
93
+ return self.is_active and requested_grant in self.grants
94
+
95
+ async def save(
96
+ self,
97
+ using_db: BaseDBAsyncClient | None = None,
98
+ update_fields: Iterable[str] | None = None,
99
+ force_create: bool = False,
100
+ force_update: bool = False,
101
+ ) -> None:
102
+ """Validate account identity and lifecycle fields before persistence."""
103
+ fields_to_update = set(update_fields) if update_fields is not None else None
104
+ if fields_to_update is None or "email" in fields_to_update:
105
+ self.update_from_dict(await normalise_account_email(self.email))
106
+ if fields_to_update is not None:
107
+ fields_to_update.add("email_comparison")
108
+ if fields_to_update is None or "expires_at" in fields_to_update:
109
+ self.update_from_dict({"expires_at": await normalise_account_expiry(self.expires_at)})
110
+ if fields_to_update is None or "grants" in fields_to_update:
111
+ self.grants = await normalise_grants(self.grants)
112
+ if fields_to_update is None or "password_hash" in fields_to_update:
113
+ if self.password_hash is not None and not PasswordOperations.is_current_verifier(self.password_hash):
114
+ raise ValueError("Account password verifier is invalid")
115
+ await super().save(
116
+ using_db=using_db,
117
+ update_fields=fields_to_update,
118
+ force_create=force_create,
119
+ force_update=force_update,
120
+ )
121
+
122
+
123
+ class AccountTokenRecord(Model):
124
+ """A revocable account token represented only by its verifier and metadata."""
125
+
126
+ id = fields.UUIDField(primary_key=True)
127
+ account = fields.ForeignKeyField("accounts.AccountRecord", related_name="tokens", on_delete=fields.CASCADE)
128
+ label = fields.TextField()
129
+ verifier = fields.CharField(max_length=64, unique=True)
130
+ scopes = fields.JSONField()
131
+ created_at = fields.DatetimeField(auto_now_add=True)
132
+
133
+ class Meta: # ty: ignore[invalid-attribute-override]
134
+ table = "account_tokens"
135
+
136
+ async def save(
137
+ self,
138
+ using_db: BaseDBAsyncClient | None = None,
139
+ update_fields: Iterable[str] | None = None,
140
+ force_create: bool = False,
141
+ force_update: bool = False,
142
+ ) -> None:
143
+ """Validate immutable token properties and its mutable label."""
144
+ fields_to_update = set(update_fields) if update_fields is not None else None
145
+ if self._saved_in_db and (fields_to_update is None or fields_to_update - {"label"}):
146
+ raise ValueError("Only an account token label may be updated")
147
+ if fields_to_update is None or "label" in fields_to_update:
148
+ if not self.label:
149
+ raise ValueError("Account token label is required")
150
+ if fields_to_update is None or "verifier" in fields_to_update:
151
+ if len(self.verifier) != 64 or any(character not in "0123456789abcdef" for character in self.verifier):
152
+ raise ValueError("Account token verifier is invalid")
153
+ if fields_to_update is None or "scopes" in fields_to_update:
154
+ self.scopes = await normalise_grants(self.scopes)
155
+ await super().save(
156
+ using_db=using_db,
157
+ update_fields=fields_to_update,
158
+ force_create=force_create,
159
+ force_update=force_update,
160
+ )
161
+
162
+
163
+ def validate_account_email(email: str) -> str:
164
+ """Validate and canonicalise an email identifier without deliverability checks."""
165
+ try:
166
+ validated = validate_email(email, check_deliverability=False)
167
+ except EmailNotValidError as error:
168
+ raise ValueError("Account email identifier is invalid") from error
169
+ local_part, _, _ = validated.original.rpartition("@")
170
+ canonical_email = f"{local_part}@{validated.domain.lower()}"
171
+ if len(canonical_email.encode("utf-8")) > 254:
172
+ raise ValueError("Account email identifier is too long")
173
+ return canonical_email
174
+
175
+
176
+ class AccountExpiryTimezoneError(ValueError):
177
+ """Raised when an account expiry omits its required timezone."""
178
+
179
+
180
+ async def normalise_account_email(email: str) -> dict[str, str]:
181
+ """Validate and normalise an email identifier for asynchronous model use."""
182
+ canonical_email = validate_account_email(email)
183
+ return {"email": canonical_email, "email_comparison": canonical_email.casefold()}
184
+
185
+
186
+ async def normalise_account_expiry(expires_at: datetime | None) -> datetime | None:
187
+ """Validate and convert an optional account expiry to UTC."""
188
+ if expires_at is None:
189
+ return None
190
+ if expires_at.tzinfo is None or expires_at.utcoffset() is None:
191
+ raise AccountExpiryTimezoneError("Account expiry must include a timezone")
192
+ return expires_at.astimezone(timezone.utc)
193
+
194
+
195
+ async def normalise_grants(grants: Iterable[Grant | str]) -> list[str]:
196
+ """Validate a complete account grant or token scope set."""
197
+ if isinstance(grants, str):
198
+ raise ValueError("Grant set must be an array")
199
+ values = list(grants)
200
+ try:
201
+ normalised = [str(Grant(value)) for value in values]
202
+ except ValueError as error:
203
+ raise ValueError("Grant set contains an unknown grant") from error
204
+ if len(set(normalised)) != len(normalised):
205
+ raise ValueError("Grant set must not contain duplicates")
206
+ return normalised