Skip to content

Commit

Permalink
Implement /api/v1/authorize endpoint.
Browse files Browse the repository at this point in the history
  • Loading branch information
nekitdev committed Feb 20, 2024
1 parent 66c4bfe commit d3161f9
Show file tree
Hide file tree
Showing 11 changed files with 134 additions and 24 deletions.
25 changes: 25 additions & 0 deletions melody/kit/authorization/code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from attrs import frozen
from typing_extensions import Self
from melody.shared.converter import CONVERTER

from melody.shared.typing import Data

__all__ = ("AuthorizationCode", "AuthorizationCodeData")


class AuthorizationCodeData(Data):
code: str
state: str


@frozen()
class AuthorizationCode:
code: str
state: str

@classmethod
def from_data(cls, data: AuthorizationCodeData) -> Self:
return CONVERTER.structure(data, cls)

def into_data(self) -> AuthorizationCodeData:
return CONVERTER.unstructure(self) # type: ignore[no-any-return]
6 changes: 4 additions & 2 deletions melody/kit/authorization/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,20 @@


class AuthorizationContextData(Data):
client_id: str
user_id: str
client_id: str
scope: str
redirect_uri: str


@register_unstructure_hook_rename_scopes
@register_structure_hook_rename_scopes
@frozen()
class AuthorizationContext:
client_id: UUID
user_id: UUID
client_id: UUID
scopes: Scopes
redirect_uri: str

@classmethod
def from_data(cls, data: AuthorizationContextData) -> Self:
Expand Down
20 changes: 20 additions & 0 deletions melody/kit/dependencies/scopes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from fastapi import Depends, Form
from typing_extensions import Annotated

from melody.shared.tokens import Scopes

__all__ = (
# dependencies
"ScopesDependency",
# dependables
"scopes_dependency",
)

FormScopeDependency = Annotated[str, Form()]


def scopes_dependency(scope: FormScopeDependency) -> Scopes:
return Scopes.from_scope(scope)


ScopesDependency = Annotated[Scopes, Depends(scopes_dependency)]
49 changes: 45 additions & 4 deletions melody/kit/endpoints/v1/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,27 @@
from fastapi import BackgroundTasks, Form
from typing_aliases import NormalError
from typing_extensions import Annotated
from melody.kit.authorization.code import AuthorizationCode, AuthorizationCodeData
from melody.kit.authorization.context import AuthorizationContext

from melody.kit.authorization.dependencies import OptionalAuthorizationCodeDependency
from melody.kit.authorization.operations import delete_authorization_codes_with
from melody.kit.clients.dependencies import OptionalClientCredentialsDependency
from melody.kit.authorization.operations import (
delete_authorization_codes_with,
generate_authorization_code_with,
)
from melody.kit.clients.dependencies import (
FormClientCredentialsDependency,
OptionalClientCredentialsDependency,
)
from melody.kit.core import config, database, hasher, v1
from melody.kit.dependencies.emails import EmailDeliverabilityDependency, EmailDependency
from melody.kit.dependencies.scopes import ScopesDependency
from melody.kit.emails import email_message, send_email_message, support
from melody.kit.enums import Tag
from melody.kit.errors.auth import (
AuthAuthorizationCodeExpected,
AuthAuthorizationCodeRedirectURIExpected,
AuthAuthorizationCodeRedirectURIMismatch,
AuthClientCredentialsExpected,
AuthClientCredentialsMismatch,
AuthEmailConflict,
Expand Down Expand Up @@ -119,17 +130,41 @@ async def revoke(bound_token: BoundTokenDependency) -> None:
await delete_access_token(bound_token.token)


RedirectURIDependency = Annotated[str, Form()]
StateDependency = Annotated[str, Form()]


@v1.post("/authorize", tags=[Tag.AUTH], summary="Authorizes the client to access the user's data.")
async def authorize() -> None:
...
async def authorize(
context: UserTokenDependency,
client_credentials: FormClientCredentialsDependency,
redirect_uri: RedirectURIDependency,
scopes: ScopesDependency,
state: StateDependency,
) -> AuthorizationCodeData:
authorization_context = AuthorizationContext(
user_id=context.user_id,
client_id=client_credentials.id,
scopes=scopes,
redirect_uri=redirect_uri,
)

code = await generate_authorization_code_with(authorization_context)

authorization_code = AuthorizationCode(code, state)

return authorization_code.into_data()


GrantTypeDependency = Annotated[GrantType, Form()]

OptionalRedirectURIDependency = Annotated[Optional[str], Form()]


@v1.post("/tokens", tags=[Tag.AUTH], summary="Returns tokens.")
async def tokens(
grant_type: GrantTypeDependency,
redirect_uri: OptionalRedirectURIDependency = None,
authorization_context: OptionalAuthorizationCodeDependency = None,
bound_refresh_token: OptionalBoundRefreshTokenDependency = None,
client_credentials: OptionalClientCredentialsDependency = None,
Expand All @@ -140,12 +175,18 @@ async def tokens(
if authorization_context is None:
raise AuthAuthorizationCodeExpected()

if redirect_uri is None:
raise AuthAuthorizationCodeRedirectURIExpected()

if client_credentials is None:
raise AuthClientCredentialsExpected()

if client_credentials.id != authorization_context.client_id:
raise AuthClientCredentialsMismatch()

if redirect_uri != authorization_context.redirect_uri:
raise AuthAuthorizationCodeRedirectURIMismatch()

await delete_authorization_codes_with(authorization_context)

context = authorization_context.into_context()
Expand Down
14 changes: 7 additions & 7 deletions melody/kit/endpoints/v1/playlists.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import Optional
from uuid import UUID

from aiofiles import open as async_open
from async_extensions.path import Path
from fastapi import Body, Depends
from fastapi.responses import FileResponse
from typing_extensions import Annotated
Expand Down Expand Up @@ -182,9 +182,9 @@ async def get_playlist_link(playlist_id: UUID) -> FileResponse:
async def get_playlist_image(playlist_id: UUID) -> FileResponse:
uri = URI(type=EntityType.PLAYLIST, id=playlist_id)

path = config.image.path / uri.image_name
path = Path(config.image.path / uri.image_name)

if not path.exists():
if not await path.exists():
raise PlaylistImageNotFound(playlist_id)

return FileResponse(path)
Expand All @@ -203,9 +203,9 @@ async def get_playlist_image(playlist_id: UUID) -> FileResponse:
async def change_playlist_image(playlist_id: UUID, data: ImageDependency) -> None:
uri = URI(type=EntityType.PLAYLIST, id=playlist_id)

path = config.image.path / uri.image_name
path = Path(config.image.path / uri.image_name)

async with async_open(path, WRITE_BINARY) as file:
async with await path.open(WRITE_BINARY) as file:
await file.write(data)


Expand All @@ -218,9 +218,9 @@ async def change_playlist_image(playlist_id: UUID, data: ImageDependency) -> Non
async def remove_playlist_image(playlist_id: UUID) -> None:
uri = URI(type=EntityType.PLAYLIST, id=playlist_id)

path = config.image.path / uri.image_name
path = Path(config.image.path / uri.image_name)

path.unlink(missing_ok=True)
await path.unlink(missing_ok=True)


@v1.get(
Expand Down
14 changes: 7 additions & 7 deletions melody/kit/endpoints/v1/self.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import List, Optional
from uuid import UUID

from aiofiles import open as async_open
from async_extensions.path import Path
from edgedb import QueryAssertionError
from fastapi import Body, Depends
from fastapi.responses import FileResponse
Expand Down Expand Up @@ -127,9 +127,9 @@ async def get_self_image(context: ImageReadTokenDependency) -> FileResponse:

uri = URI(type=EntityType.USER, id=self_id)

path = config.image.path / uri.image_name
path = Path(config.image.path / uri.image_name)

if not path.exists():
if not await path.exists():
raise UserImageNotFound(self_id)

return FileResponse(path)
Expand All @@ -143,9 +143,9 @@ async def get_self_image(context: ImageReadTokenDependency) -> FileResponse:
async def change_self_image(context: ImageWriteTokenDependency, data: ImageDependency) -> None:
uri = URI(type=EntityType.USER, id=context.user_id)

path = config.image.path / uri.image_name
path = Path(config.image.path / uri.image_name)

async with async_open(path, WRITE_BINARY) as file:
async with await path.open(WRITE_BINARY) as file:
await file.write(data)


Expand All @@ -159,9 +159,9 @@ async def remove_self_image(context: ImageWriteTokenDependency) -> None:

uri = URI(type=EntityType.USER, id=self_id)

path = config.image.path / uri.image_name
path = Path(config.image.path / uri.image_name)

path.unlink(missing_ok=True)
await path.unlink(missing_ok=True)


@v1.get(
Expand Down
5 changes: 3 additions & 2 deletions melody/kit/endpoints/v1/users.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from uuid import UUID

from async_extensions.path import Path
from fastapi import Depends
from fastapi.responses import FileResponse
from iters.iters import iter
Expand Down Expand Up @@ -98,9 +99,9 @@ async def get_user_link(user_id: UUID) -> FileResponse:
async def get_user_image(user_id: UUID) -> FileResponse:
uri = URI(type=EntityType.USER, id=user_id)

path = config.image.path / uri.image_name
path = Path(config.image.path / uri.image_name)

if not path.exists():
if not await path.exists():
raise UserImageNotFound(user_id)

return FileResponse(path)
Expand Down
20 changes: 20 additions & 0 deletions melody/kit/errors/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
"AuthAuthorizationCodeError",
"AuthAuthorizationCodeExpected",
"AuthAuthorizationCodeInvalid",
"AuthAuthorizationCodeRedirectURIExpected",
"AuthAuthorizationCodeRedirectURIMismatch",
# verification
"AuthVerificationCodeError",
"AuthVerificationCodeInvalid",
Expand Down Expand Up @@ -228,6 +230,24 @@ def __init__(self) -> None:
super().__init__(INVALID_AUTHORIZATION_CODE)


REDIRECT_URI_EXPECTED = "redirect URI expected"


@default_code(ErrorCode.AUTH_AUTHORIZATION_CODE_REDIRECT_URI_EXPECTED)
class AuthAuthorizationCodeRedirectURIExpected(AuthAuthorizationCodeError):
def __init__(self) -> None:
super().__init__(REDIRECT_URI_EXPECTED)


REDIRECT_URI_MISMATCH = "redirect URI mismatch"


@default_code(ErrorCode.AUTH_AUTHORIZATION_CODE_REDIRECT_URI_MISMATCH)
class AuthAuthorizationCodeRedirectURIMismatch(AuthAuthorizationCodeError):
def __init__(self) -> None:
super().__init__(REDIRECT_URI_MISMATCH)


@default_code(ErrorCode.AUTH_VERIFICATION_CODE_ERROR)
class AuthVerificationCodeError(AuthError):
pass
Expand Down
2 changes: 2 additions & 0 deletions melody/kit/errors/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ class ErrorCode(Enum):
AUTH_AUTHORIZATION_CODE_ERROR = 13840
AUTH_AUTHORIZATION_CODE_EXPECTED = 13841
AUTH_AUTHORIZATION_CODE_INVALID = 13842
AUTH_AUTHORIZATION_CODE_REDIRECT_URI_EXPECTED = 13843
AUTH_AUTHORIZATION_CODE_REDIRECT_URI_MISMATCH = 13844

AUTH_VERIFICATION_CODE_ERROR = 13850
AUTH_VERIFICATION_CODE_INVALID = 13851
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ pyotp = "^2.9.0"
jinja2 = "^3.1.3"
# melody.shared
aiohttp = "^3.9.1"
aiofiles = "^23.2.1"
cattrs = "^23.2.3"
typing-aliases = "^1.4.1"
yarl = "^1.9.4"
Expand Down
2 changes: 1 addition & 1 deletion schema/default.esdl
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ module default {
required duration_ms: duration_ms;
}

type Client extending Named {
type Client extending Named, RedirectURLs {
required creator: User {
on target delete delete source;
};
Expand Down

0 comments on commit d3161f9

Please sign in to comment.