Skip to content

Commit

Permalink
Implement wallet RPC's JWT token authority
Browse files Browse the repository at this point in the history
  • Loading branch information
roshii committed Aug 4, 2023
1 parent 54db582 commit 5c1e565
Show file tree
Hide file tree
Showing 7 changed files with 431 additions and 103 deletions.
59 changes: 56 additions & 3 deletions docs/api/wallet-rpc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ servers:
- url: https://none
description: This API is called locally to a jmwalletd instance, acting as server, for each wallet owner, it is not public.
paths:
/token/refresh:
post:
security:
- bearerAuth: []
summary: refresh an access token
operationId: refreshtoken
description: Give a refresh token and get back both an access and refresh token. Access token, valid for 30 min, must be used for authenticated endpoints. refreshtoken endpoint can be called with an expired access token and a refresh token, valid 4 hours after issuance. The newly issued tokens must be used in subsequent calls since operation invalidates previously issued tokens.
responses:
'200':
$ref: '#/components/responses/RefreshToken-200-OK'
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RefreshTokenRequest'
description: token refresh parameters
/wallet/create:
post:
summary: create a new wallet
Expand Down Expand Up @@ -652,6 +672,26 @@ components:
destination:
type: string
example: "bcrt1qujp2x2fv437493sm25gfjycns7d39exjnpptzw"
RefreshTokenRequest:
type: object
required:
- refresh_token
properties:
refresh_token:
type: string
format: byte
RefreshTokenResponse:
type: object
required:
- token
- refresh_token
properties:
token:
type: string
format: byte
refresh_token:
type: string
format: byte
RunScheduleRequest:
type: object
required:
Expand Down Expand Up @@ -893,34 +933,41 @@ components:
type: string
extradata:
type: string

CreateWalletResponse:
type: object
required:
- walletname
- token
- seedphrase
- token
- refresh_token
properties:
walletname:
type: string
example: wallet.jmdat
seedphrase:
type: string
token:
type: string
format: byte
seedphrase:
refresh_token:
type: string
format: byte
UnlockWalletResponse:
type: object
required:
- walletname
- token
- refresh_token
properties:
walletname:
type: string
example: wallet.jmdat
token:
type: string
format: byte
refresh_token:
type: string
format: byte
DirectSendResponse:
type: object
required:
Expand Down Expand Up @@ -1100,6 +1147,12 @@ components:
application/json:
schema:
$ref: "#/components/schemas/ListWalletsResponse"
RefreshToken-200-OK:
description: "access token refreshed successfully"
content:
application/json:
schema:
$ref: "#/components/schemas/RefreshTokenResponse"
Session-200-OK:
description: "successful heartbeat response"
content:
Expand Down
94 changes: 94 additions & 0 deletions jmclient/jmclient/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import datetime
import os
from typing import Optional

import jwt

from jmbase.support import bintohex


class InvalidScopeError(Exception):
pass


class JMSessionValidity:
access = datetime.timedelta(minutes=30)
refresh = datetime.timedelta(hours=4)


class JMTokenSignatureKey:
algorithm = "HS256"

def __init__(self):
self.access = self.get_random_key()
self.refresh = self.get_random_key()

@staticmethod
def get_random_key(size: int = 16) -> str:
"""Create a random key has an hexadecimal string."""
return bintohex(os.urandom(size))

def reset(self, refresh_token_only: bool):
"""Invalidate previously issued token(s) by creating new signature key(s)."""
self.refresh = self.get_random_key()
if not refresh_token_only:
self.access = self.get_random_key()


class JMTokenAuthority:
"""Manage authorization tokens."""

validity = JMSessionValidity()

def __init__(self, wallet_name: Optional[str] = None):
self.signature = JMTokenSignatureKey()
self.wallet_name = wallet_name

def verify(
self,
token: str,
scope: str = "walletrpc",
*,
is_refresh: bool = False,
verify_exp: bool = True,
):
"""Verify JWT token.
Token must have a valid signature and its scope must contain both scopes in
arguments and wallet_name property.
"""
token_type = "refresh" if is_refresh else "access"
claims = jwt.decode(
token,
getattr(self.signature, token_type),
algorithms=self.signature.algorithm,
options={"verify_exp": verify_exp},
leeway=10,
)
token_claims = set(claims.get("scope", []).split())
if not set(scope.split()) | {self.wallet_name} <= token_claims:
raise InvalidScopeError

def _issue(self, scope: str, token_type: str) -> str:
return jwt.encode(
{
"exp": datetime.datetime.utcnow() + getattr(self.validity, token_type),
"scope": f"{scope} {self.wallet_name}",
},
getattr(self.signature, token_type),
algorithm=self.signature.algorithm,
)

def issue(self, scope: str = "walletrpc") -> dict:
"""Issue a new access and refresh token for said scope.
Previously issued refresh token is invalidated.
"""
self.signature.reset(refresh_token_only=True)
return {
"token": self._issue(scope, "access"),
"refresh_token": self._issue(scope, "refresh"),
}

def reset(self):
"""Invalidate all previously issued tokens by creating new signature keys."""
self.signature.reset(refresh_token_only=False)
Loading

0 comments on commit 5c1e565

Please sign in to comment.