mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
181 lines
4.5 KiB
TypeScript
181 lines
4.5 KiB
TypeScript
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<ToSmapiFault, string>;
|
|
};
|
|
|
|
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<ToSmapiFault, string> => {
|
|
try {
|
|
return E.right(
|
|
(
|
|
jwt.verify(
|
|
smapiToken.token,
|
|
this.secret + this.version + smapiToken.key
|
|
) as any
|
|
).serviceToken
|
|
);
|
|
} catch (e) {
|
|
if (isTokenExpiredError(e)) {
|
|
const serviceToken = (
|
|
jwt.verify(
|
|
smapiToken.token,
|
|
this.secret + this.version + smapiToken.key,
|
|
{ ignoreExpiration: true }
|
|
) as any
|
|
).serviceToken;
|
|
return E.left(new ExpiredTokenError(serviceToken));
|
|
} else if (isError(e)) return E.left(new InvalidTokenError(e.message));
|
|
else return E.left(new InvalidTokenError("Failed to verify token"));
|
|
}
|
|
};
|
|
}
|