import { either as E } from "fp-ts"; import jwt from "jsonwebtoken"; import { v4 as uuid } from "uuid"; import { b64Decode, b64Encode } from "./b64"; import { Clock } from "./clock"; import logger from "./logger"; export type SmapiFault = { Fault: { faultcode: string; faultstring: string } }; export type SmapiRefreshTokenResultFault = SmapiFault & { Fault: { detail: { refreshAuthTokenResult: { authToken: string; privateKey: string }; }; }; }; function isError(thing: any): thing is Error { logger.debug("isError check", { thing }); return thing.name && thing.message; } export function isSmapiRefreshTokenResultFault( fault: SmapiFault ): fault is SmapiRefreshTokenResultFault { return (fault.Fault as any).detail?.refreshAuthTokenResult != undefined; } export type SmapiToken = { token: string; key: string; }; export interface ToSmapiFault { _tag: string; toSmapiFault(): SmapiFault } export const SMAPI_FAULT_LOGIN_UNAUTHORIZED = { Fault: { faultcode: "Client.LoginUnauthorized", faultstring: "Failed to authenticate, try Re-Authorising your account in the sonos app", }, }; export const SMAPI_FAULT_LOGIN_UNSUPPORTED = { Fault: { faultcode: "Client.LoginUnsupported", faultstring: "Missing credentials...", }, }; export class MissingLoginTokenError extends Error implements ToSmapiFault { _tag = "MissingLoginTokenError"; constructor() { super("Missing Login Token"); } toSmapiFault = () => SMAPI_FAULT_LOGIN_UNSUPPORTED; } export class InvalidTokenError extends Error implements ToSmapiFault { _tag = "InvalidTokenError"; constructor(message: string) { super(message); } toSmapiFault = () => SMAPI_FAULT_LOGIN_UNAUTHORIZED; } export function isExpiredTokenError(thing: any): thing is ExpiredTokenError { return thing._tag == "ExpiredTokenError"; } export class ExpiredTokenError extends Error implements ToSmapiFault { _tag = "ExpiredTokenError"; expiredToken: string; constructor(expiredToken: string) { super("SMAPI token has expired"); this.expiredToken = expiredToken; } toSmapiFault = () => ({ Fault: { faultcode: "Client.TokenRefreshRequired", faultstring: "Token has expired", }, }); } export type SmapiAuthTokens = { issue: (serviceToken: string) => SmapiToken; verify: (smapiToken: SmapiToken) => E.Either; }; type TokenExpiredError = { name: string; message: string; expiredAt: number; }; function isTokenExpiredError(thing: any): thing is TokenExpiredError { return thing.name == "TokenExpiredError"; } export const smapiTokenAsString = (smapiToken: SmapiToken) => b64Encode( JSON.stringify({ token: smapiToken.token, key: smapiToken.key, }) ); export const smapiTokenFromString = (smapiTokenString: string): SmapiToken => JSON.parse(b64Decode(smapiTokenString)); export const SMAPI_TOKEN_VERSION = 2; export class JWTSmapiLoginTokens implements SmapiAuthTokens { private readonly clock: Clock; private readonly secret: string; private readonly expiresIn: string; private readonly version: number; private readonly keyGenerator: () => string; constructor( clock: Clock, secret: string, expiresIn: string, keyGenerator: () => string = uuid, version: number = SMAPI_TOKEN_VERSION ) { this.clock = clock; this.secret = secret; this.expiresIn = expiresIn; this.version = version; this.keyGenerator = keyGenerator; } issue = (serviceToken: string) => { const key = this.keyGenerator(); return { token: jwt.sign( { serviceToken, iat: this.clock.now().unix() }, this.secret + this.version + key, { expiresIn: this.expiresIn } ), key, }; }; verify = (smapiToken: SmapiToken): E.Either => { logger.debug("Verifying JWT", { token: smapiToken.token, key: smapiToken.key, secret: this.secret, version: this.version, secretKey: this.secret + this.version + smapiToken.key, }); try { return E.right( ( jwt.verify( smapiToken.token, this.secret + this.version + smapiToken.key ) as any ).serviceToken ); } catch (e) { const err = e as Error; if (isTokenExpiredError(e)) { logger.debug("JWT token expired, will attempt refresh", { expiredAt: (e as TokenExpiredError).expiredAt }); const serviceToken = ( jwt.verify( smapiToken.token, this.secret + this.version + smapiToken.key, { ignoreExpiration: true } ) as any ).serviceToken; return E.left(new ExpiredTokenError(serviceToken)); } else { logger.warn("JWT verification failed - token may be invalid or from different secret", { message: err.message }); if (isError(e)) return E.left(new InvalidTokenError(err.message)); else return E.left(new InvalidTokenError("Failed to verify token")); } } }; }