This commit is contained in:
2025-08-07 22:10:10 -03:00
parent b572291dff
commit 78c4a4ad30
17 changed files with 555 additions and 304 deletions

View File

@@ -3,12 +3,11 @@ import os
ISSUER: str = os.getenv('ISSUER') # type: ignore
OAUTH2_TABLE: str = os.getenv('OAUTH2_TABLE') # type: ignore
DYNAMODB_SORT_KEY = os.getenv('DYNAMODB_SORT_KEY')
OAUTH2_SCOPES_SUPPORTED = os.getenv('OAUTH2_SCOPES_SUPPORTED')
OAUTH2_SCOPES_SUPPORTED: str = os.getenv('OAUTH2_SCOPES_SUPPORTED', '')
JWT_SECRET: str = os.environ.get('JWT_SECRET') # type: ignore
JWT_ALGORITHM = 'HS256'
JWT_EXP_SECONDS = 900 # 15 minutes
REFRESH_TOKEN_EXP_SECONDS = 7 * 86400 # 7 days
ACCESS_TOKEN_EXP_SECONDS = 3600 # 1 hour
REFRESH_TOKEN_EXP_SECONDS = 14 * 86400 # 14 days

View File

@@ -1,180 +0,0 @@
import os
import secrets
from collections import defaultdict
from urllib.parse import parse_qs
from authlib.oauth2 import AuthorizationServer as _AuthorizationServer
from authlib.oauth2.rfc6749 import AuthorizationCodeMixin, ClientMixin, TokenMixin
from authlib.oauth2.rfc6749.requests import JsonRequest as _JsonRequest
from authlib.oauth2.rfc6749.requests import OAuth2Payload as _OAuth2Payload
from authlib.oauth2.rfc6749.requests import OAuth2Request as _OAuth2Request
from aws_lambda_powertools.event_handler.api_gateway import Response
from aws_lambda_powertools.utilities.data_classes.api_gateway_proxy_event import (
APIGatewayProxyEventV2,
)
OAUTH2_SCOPES_SUPPORTED = os.getenv('OAUTH2_SCOPES_SUPPORTED')
class OAuth2Payload(_OAuth2Payload):
def __init__(self, request: APIGatewayProxyEventV2):
self._request = request
@property
def decoded_body(self):
# TODO
body = parse_qs(self._request.decoded_body, keep_blank_values=True)
return {k: v[0] if len(v) == 1 else v for k, v in body.items()}
@property
def data(self):
"""Combines query string parameters and the request body"""
return self._request.query_string_parameters | self.decoded_body
@property
def datalist(self) -> dict[str, list]:
values = defaultdict(list)
for k, v in self.data.items():
values[k].extend([v])
return values
class JsonRequest(_JsonRequest):
def __init__(self, request: APIGatewayProxyEventV2):
uri = f'https://{request.request_context.domain_name}'
super().__init__(
request.request_context.http.method,
uri,
request.headers,
)
class OAuth2Request(_OAuth2Request):
def __init__(self, request: APIGatewayProxyEventV2):
uri = f'https://{request.request_context.domain_name}'
super().__init__(
request.request_context.http.method,
uri,
request.headers,
)
self._request = request
self.payload = OAuth2Payload(request)
@property
def args(self):
return self._request.query_string_parameters
@property
def form(self) -> dict[str, str]:
return self.payload.decoded_body
class OAuth2Client(ClientMixin):
def __init__(
self,
client_id: str,
client_secret: str,
redirect_uris: list,
response_types: list,
grant_types: list,
token_endpoint_auth_method: str = 'client_secret_basic',
) -> None:
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uris = redirect_uris
self.response_types = response_types
self.grant_types = grant_types
self.token_endpoint_auth_method = token_endpoint_auth_method
def get_client_id(self):
return self.client_id
def get_default_redirect_uri(self) -> str: # type: ignore
if self.redirect_uris:
return self.redirect_uris[0]
def check_response_type(self, response_type):
return response_type in self.response_types
def check_redirect_uri(self, redirect_uri):
return redirect_uri in self.redirect_uris
def check_endpoint_auth_method(self, method, endpoint):
if endpoint == 'token':
return self.token_endpoint_auth_method == method
return True
def check_grant_type(self, grant_type):
return grant_type in self.grant_types
def check_client_secret(self, client_secret):
return secrets.compare_digest(self.client_secret, client_secret)
class OAuth2Token(TokenMixin): ...
class AuthorizationCode(AuthorizationCodeMixin):
def __init__(
self,
user_id: str,
code: str,
client_id: str,
redirect_uri: str,
response_type: str,
scope: str,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
nonce: str | None = None,
) -> None:
self.user_id = user_id
self.code = code
self.client_id = client_id
self.redirect_uri = redirect_uri
self.response_type = response_type
self.scope = scope
self.code_challenge = code_challenge
self.code_challenge_method = code_challenge_method
self.nonce = nonce
def get_redirect_uri(self):
return self.redirect_uri
def get_scope(self):
return self.scope
class AuthorizationServer(_AuthorizationServer):
def __init__(self, query_client, save_token) -> None:
super().__init__(
scopes_supported=OAUTH2_SCOPES_SUPPORTED,
)
self._query_client = query_client
self._save_token = save_token
def save_token(self, token, request):
return self._save_token(token, request)
def query_client(self, client_id: str):
return self._query_client(client_id)
def create_oauth2_request(self, request: APIGatewayProxyEventV2) -> OAuth2Request:
return OAuth2Request(request)
def create_json_request(self, request: APIGatewayProxyEventV2) -> JsonRequest:
return JsonRequest(request)
def handle_response(self, status, body, headers):
return Response(
status_code=status,
body=body,
headers=headers,
)
def send_signal(self, name, *args, **kwargs):
pass

View File

@@ -0,0 +1,154 @@
import os
import authlib.oauth2 as oauth2
import authlib.oauth2.rfc6749.requests as requests
from authlib.common.security import generate_token
from authlib.oauth2.rfc6750 import BearerTokenGenerator
from aws_lambda_powertools.event_handler.api_gateway import Response
from aws_lambda_powertools.event_handler.exceptions import NotFoundError
from aws_lambda_powertools.utilities.data_classes.api_gateway_proxy_event import (
APIGatewayProxyEventV2,
)
from layercake.dateutils import now, ttl
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from config import ACCESS_TOKEN_EXP_SECONDS, REFRESH_TOKEN_EXP_SECONDS
from .client import OAuth2Client
from .requests import APIGatewayJsonRequest, APIGatewayOAuth2Request
DYNAMODB_SORT_KEY = os.getenv('DYNAMODB_SORT_KEY')
OAUTH2_SCOPES_SUPPORTED = os.getenv('OAUTH2_SCOPES_SUPPORTED')
class AuthorizationServer(oauth2.AuthorizationServer):
def __init__(
self,
*,
persistence_layer: DynamoDBPersistenceLayer,
) -> None:
self._persistence_layer = persistence_layer
super().__init__(
scopes_supported=(
set(OAUTH2_SCOPES_SUPPORTED.split()) if OAUTH2_SCOPES_SUPPORTED else []
)
)
self.register_token_generator(
'default',
BearerTokenGenerator(
access_token_generator=create_token_generator(42),
refresh_token_generator=create_token_generator(48),
),
)
def save_token(
self,
token: dict,
request: requests.OAuth2Request,
) -> None:
if not request.payload:
raise ValueError('Missing request payload')
if not request.user:
raise ValueError('Missing request user')
now_ = now()
client_id = request.payload.client_id
access_token = token['access_token']
refresh_token = token.get('refresh_token')
token_type = token['token_type']
scope = token['scope']
issued_at = int(now_.timestamp())
with self._persistence_layer.transact_writer() as transact:
transact.put(
item={
'id': 'OAUTH2#TOKEN',
'sk': f'ACCESS_TOKEN#{access_token}',
'client_id': client_id,
'token_type': token_type,
'refresh_token': refresh_token,
'scope': scope,
'user': request.user,
'expires_in': ACCESS_TOKEN_EXP_SECONDS,
'issued_at': issued_at,
'ttl': ttl(start_dt=now_, seconds=ACCESS_TOKEN_EXP_SECONDS),
},
)
if refresh_token:
transact.put(
item={
'id': 'OAUTH2#TOKEN',
'sk': f'REFRESH_TOKEN#{refresh_token}',
'client_id': client_id,
'token_type': token_type,
'access_token': access_token,
'scope': scope,
'user': request.user,
'expires_in': REFRESH_TOKEN_EXP_SECONDS,
'issued_at': issued_at,
'ttl': ttl(start_dt=now_, seconds=REFRESH_TOKEN_EXP_SECONDS),
},
)
return None
def query_client(self, client_id: str):
client = self._persistence_layer.collection.get_item(
KeyPair(pk='OAUTH2', sk=f'CLIENT_ID#{client_id}'),
exc_cls=ClientNotFoundError,
)
_, client_id = client.get(DYNAMODB_SORT_KEY, '').split('#')
return OAuth2Client(
client_id=client_id,
client_secret=client['client_secret'],
scope=client['scope'],
redirect_uris=client['redirect_uris'],
response_types=client['response_types'],
grant_types=client['grant_types'],
token_endpoint_auth_method=client.get(
'token_endpoint_auth_method', 'client_secret_basic'
),
)
def create_oauth2_request(
self, request: APIGatewayProxyEventV2
) -> APIGatewayOAuth2Request:
return APIGatewayOAuth2Request(request)
def create_json_request(
self, request: APIGatewayProxyEventV2
) -> APIGatewayJsonRequest:
return APIGatewayJsonRequest(request)
def handle_response(self, status: int, body, headers):
return Response(
status_code=status,
body=body,
headers=headers,
)
def send_signal(self, name: str, *args, **kwargs) -> None:
# after_authenticate_client
# when client is authenticated
# after_revoke_token
# when token is revoked
...
class ClientNotFoundError(NotFoundError):
def __init__(self, *_):
super().__init__('Client not found')
def create_token_generator(length: int = 42):
def token_generator(*args, **kwargs):
return generate_token(length)
return token_generator

View File

@@ -0,0 +1,60 @@
import secrets
from authlib.oauth2.rfc6749 import (
ClientMixin,
list_to_scope,
scope_to_list,
)
class OAuth2Client(ClientMixin):
def __init__(
self,
client_id: str,
client_secret: str,
scope: str,
redirect_uris: list,
response_types: list,
grant_types: list,
token_endpoint_auth_method: str = 'client_secret_basic',
) -> None:
self.client_id = client_id
self.client_secret = client_secret
self.scope = scope
self.redirect_uris = redirect_uris
self.response_types = response_types
self.grant_types = grant_types
self.token_endpoint_auth_method = token_endpoint_auth_method
def get_client_id(self):
return self.client_id
def get_allowed_scope(self, scope) -> str:
if not scope:
return ''
allowed = set(self.scope.split())
scopes = scope_to_list(scope)
return list_to_scope([s for s in scopes if s in allowed])
def get_default_redirect_uri(self) -> str: # type: ignore
if self.redirect_uris:
return self.redirect_uris[0]
def check_response_type(self, response_type):
return response_type in self.response_types
def check_redirect_uri(self, redirect_uri):
return redirect_uri in self.redirect_uris
def check_endpoint_auth_method(self, method, endpoint):
if endpoint == 'token':
return self.token_endpoint_auth_method == method
return True
def check_grant_type(self, grant_type):
return grant_type in self.grant_types
def check_client_secret(self, client_secret):
return secrets.compare_digest(self.client_secret, client_secret)

View File

@@ -0,0 +1,65 @@
from collections import defaultdict
from urllib.parse import parse_qs
import authlib.oauth2.rfc6749.requests as requests
from aws_lambda_powertools.utilities.data_classes.api_gateway_proxy_event import (
APIGatewayProxyEventV2,
)
class APIGatewayOAuth2Payload(requests.OAuth2Payload):
def __init__(self, request: APIGatewayProxyEventV2):
self._request = request
@property
def decoded_body(self):
# TODO
body = parse_qs(self._request.decoded_body, keep_blank_values=True)
return {k: v[0] if len(v) == 1 else v for k, v in body.items()}
@property
def data(self):
"""Combines query string parameters and the request body"""
return self._request.query_string_parameters | self.decoded_body
@property
def datalist(self) -> dict[str, list]:
values = defaultdict(list)
for k, v in self.data.items():
values[k].extend([v])
return values
class APIGatewayJsonRequest(requests.JsonRequest):
def __init__(self, request: APIGatewayProxyEventV2):
uri = f'https://{request.request_context.domain_name}'
super().__init__(
request.request_context.http.method,
uri,
request.headers,
)
class APIGatewayOAuth2Request(requests.OAuth2Request):
def __init__(self, request: APIGatewayProxyEventV2):
uri = f'https://{request.request_context.domain_name}'
super().__init__(
request.request_context.http.method,
uri,
request.headers,
)
self._request = request
self.payload = APIGatewayOAuth2Payload(request)
@property
def args(self):
# @TODO
return self._request.query_string_parameters
@property
def form(self) -> dict[str, str]:
# @TODO
return self.payload.decoded_body

View File

@@ -0,0 +1,97 @@
import time
from authlib.oauth2.rfc6749 import (
AuthorizationCodeMixin,
ClientMixin,
TokenMixin,
)
from layercake.dateutils import fromisoformat
class OAuth2AuthorizationCode(AuthorizationCodeMixin):
def __init__(
self,
user_id: str,
code: str,
client_id: str,
redirect_uri: str,
response_type: str,
scope: str,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
nonce: str | None = None,
**kwargs,
) -> None:
self.user_id = user_id
self.code = code
self.client_id = client_id
self.redirect_uri = redirect_uri
self.response_type = response_type
self.scope = scope
self.code_challenge = code_challenge
self.code_challenge_method = code_challenge_method
self.nonce = nonce
auth_time = fromisoformat(kwargs.get('created_at', '')) or now()
self.auth_time = int(auth_time.timestamp())
def get_redirect_uri(self):
return self.redirect_uri
def get_scope(self):
return self.scope
def get_nonce(self):
return self.nonce
def get_auth_time(self):
return self.auth_time
def get_acr(self):
return '0'
def get_amr(self):
return []
class OAuth2Token(TokenMixin):
def __init__(
self,
user: dict,
client_id: str,
scope: str,
expires_in: int,
issued_at: int,
access_token: str | None = None,
refresh_token: str | None = None,
**_,
) -> None:
self.user = user
self.client_id = client_id
self.scope = scope
self.expires_in = expires_in
self.issued_at = issued_at
self.access_token = access_token
self.refresh_token = refresh_token
def get_user(self) -> dict:
return self.user
def check_client(self, client: ClientMixin):
return self.client_id == client.get_client_id()
def get_scope(self) -> str:
return self.scope
def get_expires_in(self) -> int:
return self.expires_in
def is_revoked(self) -> bool:
return False
def is_expired(self) -> bool:
if not self.expires_in:
return False
expires_at = self.issued_at + self.expires_in
return expires_at < time.time()

View File

@@ -1,110 +1,89 @@
from authlib.oauth2.rfc6749 import TokenMixin, grants
from authlib.oauth2 import OAuth2Request
from authlib.oauth2.rfc6749 import ClientMixin, TokenMixin, grants
from authlib.oauth2.rfc7636 import CodeChallenge
from authlib.oidc.core import OpenIDCode as OpenIDCode_
from authlib.oidc.core import UserInfo
from aws_lambda_powertools.event_handler.exceptions import NotFoundError
from layercake.dateutils import now, ttl
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from layercake.funcs import omit, pick
from layercake.funcs import pick
from boto3clients import dynamodb_client
from config import DYNAMODB_SORT_KEY, OAUTH2_TABLE
from integrations.apigateway_oauth2 import (
AuthorizationCode,
from config import ISSUER, JWT_ALGORITHM, OAUTH2_TABLE
from integrations.apigateway_oauth2.authorization_server import (
AuthorizationServer,
OAuth2Client,
)
from integrations.apigateway_oauth2.tokens import (
OAuth2AuthorizationCode,
OAuth2Token,
)
oauth2_layer = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
DUMMY_JWT_CONFIG = {
'key': 'secret-key',
'alg': 'HS256',
'iss': 'https://authlib.org',
'exp': 3600,
}
def create_save_token_func(persistence_layer: DynamoDBPersistenceLayer):
def save_token(token, request) -> OAuth2Token:
print('save_token')
return OAuth2Token()
return save_token
def create_query_client_func(persistence_layer: DynamoDBPersistenceLayer):
class ClientNotFoundError(NotFoundError):
def __init__(self, *_):
super().__init__('Client not found')
def query_client(client_id) -> OAuth2Client:
client = persistence_layer.collection.get_item(
KeyPair(
pk='OAUTH2_CLIENT',
sk=f'CLIENT_ID#{client_id}',
),
exc_cls=ClientNotFoundError,
)
_, client_id = client.get(DYNAMODB_SORT_KEY, '').split('#')
return OAuth2Client(
client_id=client_id,
client_secret=client['secret'],
redirect_uris=client['redirect_uris'],
response_types=client['response_types'],
grant_types=client['grant_types'],
token_endpoint_auth_method=client['token_endpoint_auth_method'],
)
return query_client
class OpenIDCode(OpenIDCode_):
def exists_nonce(self, nonce, request):
def exists_nonce(self, nonce: str, request: OAuth2Request) -> bool:
if not request.payload:
raise ValueError('Missing request payload')
nonce_ = oauth2_layer.get_item(
KeyPair(
f'OAUTH2_CODE#CLIENT_ID#{request.payload.client_id}', # type:ignore
f'NONCE#{nonce}',
)
KeyPair(pk='OAUTH2#CODE', sk=f'NONCE#{nonce}'),
)
return bool(nonce_)
def get_jwt_config(self, grant):
return DUMMY_JWT_CONFIG
return {
'key': 'secret-key',
'alg': JWT_ALGORITHM,
'iss': ISSUER,
'exp': 3600,
}
def generate_user_info(self, user, scope):
def generate_user_info(self, user: dict, scope: str) -> UserInfo:
return UserInfo(
sub=user.id,
name=user.name,
email=user.email,
sub=user['id'],
name=user['name'],
email=user['email'],
email_verified=user.get('email_verified', False),
).filter(scope)
class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
TOKEN_ENDPOINT_AUTH_METHODS = ['client_secret_basic', 'client_secret_post', 'none']
TOKEN_ENDPOINT_AUTH_METHODS = [
'client_secret_basic',
'client_secret_post',
'none',
]
def save_authorization_code(self, code: str, request):
client_id: str = request.payload.client_id # type: ignore
data: dict = request.payload.data # type: ignore
user: dict = request.user # type: ignore
def save_authorization_code(
self,
code: str,
request: OAuth2Request,
) -> None:
if not request.payload:
raise ValueError('Missing request payload')
if not request.user:
raise ValueError('Missing request user')
client_id: str = request.payload.client_id
data: dict = request.payload.data
user: dict = request.user
nonce: str | None = data.get('nonce')
code_challenge: str | None = data.get('code_challenge')
code_challenge_method: str | None = data.get('code_challenge_method')
now_ = now()
ttl_ = ttl(start_dt=now_, minutes=15)
ttl_ = ttl(start_dt=now_, minutes=10)
with oauth2_layer.transact_writer() as transact:
transact.put(
item={
'id': f'OAUTH2_CODE#CLIENT_ID#{client_id}',
'id': 'OAUTH2#CODE',
'sk': f'CODE#{code}',
'redirect_uri': request.payload.redirect_uri, # type: ignore
'scope': request.payload.scope, # type: ignore
'redirect_uri': request.payload.redirect_uri,
'response_type': request.payload.response_type,
'scope': request.payload.scope,
'client_id': client_id,
'user_id': user['id'],
'nonce': nonce,
'code_challenge': code_challenge,
@@ -117,56 +96,90 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
if nonce:
transact.put(
item={
'id': f'OAUTH2_CODE#CLIENT_ID#{client_id}',
'id': 'OAUTH2#CODE',
'sk': f'NONCE#{nonce}',
'client_id': client_id,
'code': code,
'created_at': now_,
'ttl': ttl_,
},
)
def query_authorization_code(self, code, client):
client_id = client.get_client_id()
def query_authorization_code(
self,
code: str,
client: ClientMixin,
) -> OAuth2AuthorizationCode:
auth_code = oauth2_layer.get_item(
KeyPair(pk='OAUTH2#CODE', sk=f'CODE#{code}'),
)
return OAuth2AuthorizationCode(
code=code,
**auth_code,
)
def delete_authorization_code(
self,
authorization_code: OAuth2AuthorizationCode,
) -> None:
oauth2_layer.delete_item(
KeyPair(
pk=f'OAUTH2_CODE#CLIENT_ID#{client_id}',
sk=f'CODE#{code}',
pk='OAUTH2#CODE',
sk=f'CODE#{authorization_code.code}',
),
)
return AuthorizationCode(
client_id=client_id,
code=code,
**omit(('id', 'sk'), auth_code),
)
def delete_authorization_code(self, authorization_code):
print('authorization_code')
def authenticate_user(self, authorization_code):
def authenticate_user(
self,
authorization_code: OAuth2AuthorizationCode,
) -> dict:
user = oauth2_layer.get_item(
KeyPair(
pk=authorization_code.user_id,
sk='0',
),
)
return pick(('id', 'name', 'email'), user)
return pick(('id', 'name', 'email', 'email_verified'), user)
class RefreshTokenGrant(grants.RefreshTokenGrant):
TOKEN_ENDPOINT_AUTH_METHODS = ['client_secret_basic', 'client_secret_post', 'none']
INCLUDE_NEW_REFRESH_TOKEN = True
def authenticate_refresh_token(self, refresh_token: str) -> TokenMixin: ...
def authenticate_refresh_token(self, refresh_token: str, **kwargs) -> TokenMixin:
token = oauth2_layer.get_item(
KeyPair(
pk='OAUTH2#TOKEN',
sk=f'REFRESH_TOKEN#{refresh_token}',
)
)
def authenticate_user(self, refresh_token): ...
return OAuth2Token(
client_id=token['client_id'],
scope=token['scope'],
expires_in=int(token['expires_in']),
issued_at=int(token['issued_at']),
user=token['user'],
refresh_token=refresh_token,
)
def revoke_old_credential(self, refresh_token: TokenMixin) -> None: ...
def authenticate_user(self, refresh_token: TokenMixin):
return refresh_token.get_user()
def revoke_old_credential(self, refresh_token: TokenMixin) -> None:
refresh_token_ = getattr(refresh_token, 'refresh_token')
if refresh_token_:
oauth2_layer.delete_item(
KeyPair(
pk='OAUTH2#TOKEN',
sk=f'REFRESH_TOKEN#{refresh_token_}',
)
)
server = AuthorizationServer(
query_client=create_query_client_func(oauth2_layer),
save_token=create_save_token_func(oauth2_layer),
)
server = AuthorizationServer(persistence_layer=oauth2_layer)
server.register_grant(
AuthorizationCodeGrant,
[

View File

@@ -1,6 +1,6 @@
from aws_lambda_powertools.event_handler.api_gateway import Router
from config import ISSUER, JWT_ALGORITHM
from config import ISSUER, JWT_ALGORITHM, OAUTH2_SCOPES_SUPPORTED
router = Router()
@@ -13,7 +13,7 @@ def openid_configuration():
'token_endpoint': f'{ISSUER}/token',
'userinfo_endpoint': f'{ISSUER}/userinfo',
'jwks_uri': f'{ISSUER}/jwks.json',
'scopes_supported': ['openid', 'profile', 'email'],
'scopes_supported': OAUTH2_SCOPES_SUPPORTED.split(),
'response_types_supported': ['code'],
'grant_types_supported': ['authorization_code', 'refresh_token'],
'subject_types_supported': ['public'],
@@ -21,5 +21,6 @@ def openid_configuration():
'token_endpoint_auth_methods_supported': [
'client_secret_basic',
'client_secret_post',
'none',
],
}

View File

@@ -1,11 +0,0 @@
import secrets
SALT_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
def gen_salt(length: int) -> str:
"""Generate a random string of SALT_CHARS with specified ``length``."""
if length <= 0:
raise ValueError('Salt length must be at least 1.')
return ''.join(secrets.choice(SALT_CHARS) for _ in range(length))