music-assistant-server

3.4 KBPY
jwt_auth.py
3.4 KB115 lines • python
1"""
2JWT token helper for Music Assistant authentication.
3
4Future OIDC Support:
5- Consuming external OIDC providers (Google, Keycloak, etc.): Can be added without
6  changes to token structure. MA would validate external OIDC tokens and issue its
7  own JWT tokens (similar to current Home Assistant OAuth flow).
8
9- Acting as OIDC provider for third parties: Would require implementing OAuth2
10  refresh token flow with a dedicated /auth/token endpoint for token refresh.
11  Short-lived access tokens (15 min) + long-lived refresh tokens would be needed
12  for proper OIDC compliance.
13"""
14
15from __future__ import annotations
16
17import secrets
18from datetime import datetime
19from typing import TYPE_CHECKING, Any
20
21import jwt
22
23from music_assistant.helpers.datetime import utc
24
25if TYPE_CHECKING:
26    from music_assistant_models.auth import User
27
28
29class JWTHelper:
30    """Helper class for JWT token operations."""
31
32    def __init__(self, secret_key: str) -> None:
33        """
34        Initialize JWT helper.
35
36        :param secret_key: Secret key for signing JWTs.
37        """
38        self.secret_key = secret_key
39        self.algorithm = "HS256"
40
41    def encode_token(
42        self,
43        user: User,
44        token_id: str,
45        token_name: str,
46        expires_at: datetime,
47        is_long_lived: bool = False,
48    ) -> str:
49        """
50        Encode a JWT token for a user.
51
52        :param user: User object to create token for.
53        :param token_id: Unique token identifier.
54        :param token_name: Human-readable token name.
55        :param expires_at: Token expiration datetime.
56        :param is_long_lived: Whether this is a long-lived token.
57        :return: Encoded JWT token string.
58        """
59        now = utc()
60        payload = {
61            "sub": user.user_id,
62            "jti": token_id,
63            "iat": int(now.timestamp()),
64            "exp": int(expires_at.timestamp()),
65            "username": user.username,
66            "role": user.role,
67            "token_name": token_name,
68            "is_long_lived": is_long_lived,
69        }
70
71        return jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
72
73    def decode_token(self, token: str, verify_exp: bool = True) -> dict[str, Any]:
74        """
75        Decode and verify a JWT token.
76
77        :param token: JWT token string to decode.
78        :param verify_exp: Whether to verify token expiration.
79        :return: Decoded token payload.
80        :raises jwt.InvalidTokenError: If token is invalid or expired.
81        """
82        payload: dict[str, Any] = jwt.decode(
83            token,
84            self.secret_key,
85            algorithms=[self.algorithm],
86            options={"verify_exp": verify_exp},
87        )
88        return payload
89
90    @staticmethod
91    def generate_secret_key() -> str:
92        """
93        Generate a secure random secret key for JWT signing.
94
95        :return: Base64-encoded 256-bit random key.
96        """
97        return secrets.token_urlsafe(32)  # 32 bytes = 256 bits
98
99    def get_token_id(self, token: str) -> str | None:
100        """
101        Extract token ID (jti) from JWT without full validation.
102
103        :param token: JWT token string.
104        :return: Token ID or None if invalid.
105        """
106        try:
107            payload: dict[str, Any] = jwt.decode(
108                token,
109                options={"verify_signature": False, "verify_exp": False},
110            )
111            jti = payload.get("jti")
112            return str(jti) if jti else None
113        except Exception:
114            return None
115