@scayle/storefront-core
Version:
Collection of essential utilities to work with the Storefront API
304 lines (303 loc) • 9.57 kB
JavaScript
import { decodeJwt } from "jose";
import { FetchError } from "../utils/fetch.mjs";
import { encodeBase64 } from "../utils/hash.mjs";
class MissingCredentialsError extends Error {
constructor() {
super("[OAuth API] No credentials configured");
this.name = "MissingCredentialsError";
}
}
export class OAuthRequestError extends Error {
constructor(message, options) {
super(message, options);
this.name = "OAuthRequestError";
}
}
function stringifyOAuthError(error) {
if (error.message) {
return error.hint ? `${error.error}: ${error.message} (hint: ${error.hint})` : `${error.error}: ${error.message}`;
} else {
return error.error;
}
}
async function oauthResponseHandler(response) {
const data = await response.json();
if (!response.ok) {
const fetchError = new FetchError(response, data);
if (data) {
const error = new OAuthRequestError(stringifyOAuthError(data), {
cause: fetchError
});
throw error;
}
throw fetchError;
}
return data;
}
function emptyOAuthResponseHandler(response) {
if (!response.ok) {
throw new FetchError(response);
}
}
export function getOAuthClient(context) {
if (!context.oauth) {
throw new Error("OAuth configuration is missing");
}
const { clientId, clientSecret, apiHost } = context.oauth;
const accessHeader = context.internalAccessHeader;
const requestHeaders = context.headers;
const host = requestHeaders.get("host");
const userAgent = requestHeaders.get("user-agent");
const contentType = requestHeaders.get("content-type");
const ip = context.originalIp;
const additionalHeaders = {};
if (ip) {
additionalHeaders["x-original-client-ip"] = ip;
}
if (userAgent) {
additionalHeaders["x-original-user-agent"] = userAgent;
}
if (host) {
additionalHeaders["x-original-host"] = host;
}
if (contentType) {
additionalHeaders["x-original-content-type"] = contentType;
}
if (accessHeader) {
additionalHeaders["x-internal-access"] = accessHeader;
}
return new OAuthClient(
{
clientId,
clientSecret,
apiHost,
additionalHeaders
},
context.log
);
}
export class OAuthClient {
/**
* Headers for API requests.
*/
headers;
/**
* Base URL for the API.
*/
baseURL;
/**
* Logger instance.
*/
logger;
/**
* OAuth client ID.
*/
clientId;
/**
* Creates a new instance of the OAuthClient.
*
* @param options OAuth client options.
* @param logger Optional logger instance.
*
* @throws {MissingCredentialsError} If client ID or client secret are missing.
*/
constructor(options, logger) {
const { clientId, clientSecret, apiHost, additionalHeaders } = options;
if (!clientId || !clientSecret) {
throw new MissingCredentialsError();
}
this.clientId = clientId;
this.baseURL = `${apiHost}/v1`;
this.logger = logger ? logger.space("auth-client") : void 0;
const basicAuthHash = encodeBase64(`${clientId}:${clientSecret}`);
this.headers = {
Authorization: `Basic ${basicAuthHash}`,
Accept: "application/json",
"Content-Type": "application/json",
...additionalHeaders
};
}
/**
* Register a new User and receive an access token.
*
* @param payload The registration data.
*
* @returns The OAuth response containing access and refresh tokens.
*
* @see https://scayle.dev/en/api-guides/authentication-api/resources/oauth-client/create-new-user
*/
async register(payload) {
this.logger?.debug("Registering user");
return await fetch(`${this.baseURL}/auth/register`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(payload)
}).then(oauthResponseHandler);
}
/**
* Login a User and receive an access token.
*
* @param payload The login credentials, including the `shopId`.
*
* @returns The OAuth response containing access and refresh tokens.
*
* @see https://scayle.dev/en/api-guides/authentication-api/resources/oauth-client/log-in-users
*/
async login(payload) {
this.logger?.debug("Logging in");
return await fetch(`${this.baseURL}/auth/login`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(payload)
}).then(oauthResponseHandler);
}
/**
* Login a User as a guest and receive an access token.
*
* @param payload The guest login data.
*
* @returns The OAuth response containing access and refresh tokens.
*
* @see https://scayle.dev/en/api-guides/authentication-api/resources/oauth-client/log-in-users-as-guest
*/
async guestLogin(payload) {
this.logger?.debug("Logging in as guest");
return await fetch(`${this.baseURL}/auth/login/guest`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(payload)
}).then(oauthResponseHandler);
}
/**
* Send a reset password email to a User.
*
* @param payload The data for sending the reset password email.
*
* @returns Nothing (resolves when the request completes successfully).
*
* @see https://scayle.dev/en/api-guides/authentication-api/resources/oauth-client/send-password-reset-email
*/
async sendPasswordResetEmail(payload) {
this.logger?.debug("Sending password reset email");
await fetch(`${this.baseURL}/auth/password/send-reset-email`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(payload)
}).then(emptyOAuthResponseHandler);
}
/**
* Update password by using hash.
* All older tokens of the User are also invalidated.
*
* @param payload The data for updating the password by hash.
*
* @returns The new OAuth response with updated tokens.
*
* @see https://scayle.dev/en/api-guides/authentication-api/resources/oauth-client/update-password-by-hash
*/
async updatePasswordByHash(payload) {
this.logger?.debug("Updating password by hash");
return await fetch(`${this.baseURL}/auth/password/update-by-hash`, {
method: "PUT",
headers: this.headers,
body: JSON.stringify(payload)
}).then(oauthResponseHandler);
}
/**
* Update password via plain string.
*
* @param payload The data for updating the password (current and new password).
* @param accessToken The current access token.
*
* @returns Nothing (resolves when the request completes successfully).
*/
async updatePassword(payload, accessToken) {
this.logger?.debug("Updating password");
return await fetch(`${this.baseURL}/auth/password`, {
method: "PUT",
headers: {
...this.headers,
Authorization: `Bearer ${accessToken}`
},
body: JSON.stringify(payload)
}).then(emptyOAuthResponseHandler);
}
/**
* Generate a new access token via a refresh token.
*
* @param payload The refresh token request data.
* @returns The new OAuth response with updated access token.
*/
async refreshToken(payload) {
this.logger?.debug("Refreshing access token");
return await fetch(`${this.baseURL}/oauth/token`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(payload)
}).then(oauthResponseHandler);
}
/**
* Validate a token.
*
* @param accessToken The access token to validate.
*
* @returns Nothing (resolves if the token is valid, rejects otherwise).
*
* @see https://scayle.dev/en/api-guides/authentication-api/resources/bearer-auth/validate-present-token
*/
async validateToken(accessToken) {
this.logger?.debug("Validating access token");
await fetch(`${this.baseURL}/oauth/token/validate`, {
headers: {
...this.headers,
Authorization: `Bearer ${accessToken}`
}
}).then(emptyOAuthResponseHandler);
}
/**
* Revoke an Access Token and all related Refresh Tokens.
*
* Uses a valid Bearer Access Token in the Authorization header and will
* revoke the token with the given ID (_which could be a different token_).
* If a external identity provider was used for the target token,
* the corresponding IDP-AccessToken and IDP-RefreshToken will be revoked as well.
* In case the identity provider does not support revoking tokens over api calls
* (_because a frontend redirect is required_) this step will be skipped and the IDP-Tokens
* will remain valid until they expire or are revoked from IDP side.
*
* @param shopId The ID of the shop.
* @param accessToken The access token to use for authorization.
* @returns Nothing (resolves when the token is successfully revoked).
*
* @see https://scayle.dev/en/api-guides/authentication-api/resources/bearer-auth/delete-access-token-with-id
*/
async revokeToken(shopId, accessToken) {
this.logger?.debug("Revoking access token");
const decodedAccessToken = decodeJwt(accessToken);
await fetch(`${this.baseURL}/oauth/tokens/${decodedAccessToken.jti}`, {
method: "DELETE",
headers: {
...this.headers,
"X-Shop-Id": `${shopId}`,
Authorization: `Bearer ${accessToken}`
}
}).then(emptyOAuthResponseHandler);
}
/**
* Generate a new token based on authorization code.
*
* @param code The authorization code.
*
* @returns The new OAuth response.
*/
async generateToken(code) {
return await fetch(`${this.baseURL}/oauth/token`, {
method: "POST",
headers: this.headers,
body: JSON.stringify({
grant_type: "authorization_code",
code
})
}).then(oauthResponseHandler);
}
}