@wristband/express-auth
Version:
SDK for integrating your ExpressJS application with Wristband. Handles user authentication, session management, and token management.
269 lines (268 loc) • 11.6 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.WristbandService = void 0;
const error_1 = require("./error");
const utils_1 = require("./utils");
const constants_1 = require("./utils/constants");
const wristband_api_client_1 = require("./wristband-api-client");
/**
* Service class for making REST API calls to the Wristband platform.
*
* Handles OAuth token exchange, user information retrieval, token refresh,
* and token revocation. Most methods use HTTP Basic Authentication with
* the configured client credentials.
*
* @internal
*/
class WristbandService {
constructor(wristbandApplicationVanityDomain, clientId, clientSecret) {
if (!wristbandApplicationVanityDomain || !wristbandApplicationVanityDomain.trim()) {
throw new Error('Wristband application domain is required');
}
if (!clientId || !clientId.trim()) {
throw new Error('Client ID is required');
}
if (!clientSecret || !clientSecret.trim()) {
throw new Error('Client secret is required');
}
this.wristbandApiClient = new wristband_api_client_1.WristbandApiClient(wristbandApplicationVanityDomain);
this.clientId = clientId;
this.basicAuthHeaders = {
'Content-Type': constants_1.FORM_URLENCODED_MEDIA_TYPE,
Accept: constants_1.JSON_MEDIA_TYPE,
Authorization: `Basic ${(0, utils_1.encodeBase64)(`${clientId}:${clientSecret}`)}`,
};
}
/**
* Fetches SDK configuration from Wristband's auto-configuration endpoint.
*
* Retrieves application-specific configuration values including login URLs,
* redirect URIs, and custom domain settings.
*
* @returns Promise resolving to the SDK configuration object
* @throws {Error} When the API request fails
*/
async getSdkConfiguration() {
return this.wristbandApiClient.get(`/clients/${this.clientId}/sdk-configuration`, {
'Content-Type': constants_1.JSON_MEDIA_TYPE,
Accept: constants_1.JSON_MEDIA_TYPE,
});
}
/**
* Exchanges an authorization code for OAuth tokens.
*
* Makes a request to Wristband's token endpoint using the authorization code
* received from the callback. Uses PKCE (code verifier) for enhanced security.
*
* @param code - The authorization code from the OAuth callback
* @param redirectUri - The redirect URI used in the authorization request
* @param codeVerifier - The PKCE code verifier for this authorization request
* @returns Promise resolving to token response with access_token, id_token, and optional refresh_token
* @throws {Error} When any parameter is missing or empty
* @throws {InvalidGrantError} When the authorization code is invalid or expired
*/
async getTokens(code, redirectUri, codeVerifier) {
if (!code || !code.trim()) {
throw new Error('Authorization code is required');
}
if (!redirectUri || !redirectUri.trim()) {
throw new Error('Redirect URI is required');
}
if (!codeVerifier || !codeVerifier.trim()) {
throw new Error('Code verifier is required');
}
const authData = [
'grant_type=authorization_code',
`code=${code}`,
`redirect_uri=${encodeURIComponent(redirectUri)}`,
`code_verifier=${encodeURIComponent(codeVerifier)}`,
].join('&');
try {
return await this.wristbandApiClient.post('/oauth2/token', authData, this.basicAuthHeaders);
}
catch (error) {
if (WristbandService.hasInvalidGrantError(error)) {
throw new error_1.InvalidGrantError(WristbandService.getErrorDescription(error) || 'Invalid grant');
}
throw error;
}
}
/**
* Retrieves user information from Wristband's userinfo endpoint.
*
* Fetches OIDC-compliant user claims including profile, email, phone, and role data
* based on the scopes associated with the access token. Transforms snake_case OIDC
* claims to camelCase field names.
*
* @param accessToken - The OAuth access token
* @returns Promise resolving to structured UserInfo object with user claims
* @throws {Error} When access token is missing or empty
* @throws {TypeError} When response is invalid or missing required claims
*/
async getUserInfo(accessToken) {
if (!accessToken || !accessToken.trim()) {
throw new Error('Access token is required');
}
const userinfo = await this.wristbandApiClient.get('/oauth2/userinfo', {
Authorization: `Bearer ${accessToken}`,
'Content-Type': constants_1.JSON_MEDIA_TYPE,
Accept: constants_1.JSON_MEDIA_TYPE,
});
WristbandService.validateUserinfoResponse(userinfo);
return WristbandService.mapUserinfoClaims(userinfo);
}
/**
* Refreshes an expired access token using a refresh token.
*
* Exchanges a valid refresh token for a new set of tokens. The refresh token
* must have been obtained with the 'offline_access' scope.
*
* @param refreshToken - The refresh token
* @returns Promise resolving to new token response with fresh access_token and id_token
* @throws {Error} When refresh token is missing or empty
* @throws {InvalidGrantError} When the refresh token is invalid or expired
*/
async refreshToken(refreshToken) {
if (!refreshToken || !refreshToken.trim()) {
throw new Error('Refresh token is required');
}
const authData = `grant_type=refresh_token&refresh_token=${refreshToken}`;
try {
return await this.wristbandApiClient.post('/oauth2/token', authData, this.basicAuthHeaders);
}
catch (error) {
if (WristbandService.hasInvalidGrantError(error)) {
throw new error_1.InvalidGrantError(WristbandService.getErrorDescription(error) || 'Invalid refresh token');
}
throw error;
}
}
/**
* Revokes a refresh token to invalidate it.
*
* Makes a request to Wristband's revocation endpoint to permanently invalidate
* the refresh token. After revocation, the token can no longer be used to obtain
* new access tokens. This is typically called during logout.
*
* @param refreshToken - The refresh token to revoke
* @returns Promise that resolves when revocation is complete
* @throws {Error} When refresh token is missing or empty
*/
async revokeRefreshToken(refreshToken) {
if (!refreshToken || !refreshToken.trim()) {
throw new Error('Refresh token is required');
}
await this.wristbandApiClient.post('/oauth2/revoke', `token=${refreshToken}`, this.basicAuthHeaders);
}
/// /////////////////////////////////
// PRIVATE METHODS
/// /////////////////////////////////
/**
* Checks if an error is a FetchError containing an invalid_grant error code.
*
* @param error - The error to check
* @returns True if the error is a FetchError with an invalid_grant body
*
* @internal
*/
static hasInvalidGrantError(error) {
if (error instanceof error_1.FetchError) {
const data = error.body;
return data && typeof data === 'object' && 'error' in data && data.error === 'invalid_grant';
}
return false;
}
/**
* Extracts the error_description field from a FetchError response body.
*
* @param error - The error to inspect
* @returns The error description string, or undefined if not present
*
* @internal
*/
static getErrorDescription(error) {
if (error instanceof error_1.FetchError) {
const data = error.body;
if (data && typeof data === 'object' && 'error_description' in data) {
return data.error_description;
}
}
return undefined;
}
/**
* Validates that the userinfo response from Wristband contains all required OIDC claims.
*
* Checks for the presence and correct types of mandatory claims that Wristband
* always returns regardless of scopes: sub (userId), tnt_id (tenantId),
* app_id (applicationId), and idp_name (identityProviderName).
*
* @param data - The raw response data from the userinfo endpoint
* @throws {TypeError} When response is not an object or missing required claims
*
* @internal
*/
static validateUserinfoResponse(data) {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new TypeError('Invalid userinfo response: expected object');
}
if (!data.sub || typeof data.sub !== 'string') {
throw new TypeError('Invalid userinfo response: missing sub claim');
}
if (!data.tnt_id || typeof data.tnt_id !== 'string') {
throw new TypeError('Invalid userinfo response: missing tnt_id claim');
}
if (!data.app_id || typeof data.app_id !== 'string') {
throw new TypeError('Invalid userinfo response: missing app_id claim');
}
if (!data.idp_name || typeof data.idp_name !== 'string') {
throw new TypeError('Invalid userinfo response: missing idp_name claim');
}
}
/**
* Transforms the raw OIDC claims from Wristband's userinfo endpoint
* to the structured UserInfo type with camelCase field names.
*
* @param userinfo - Raw userinfo claims from Wristband auth SDK
* @returns Structured UserInfo object from Wristband session SDK
*/
static mapUserinfoClaims(userinfo) {
return {
// Always present
userId: userinfo.sub,
tenantId: userinfo.tnt_id,
applicationId: userinfo.app_id,
identityProviderName: userinfo.idp_name,
// Profile scope
fullName: userinfo.name ?? undefined,
givenName: userinfo.given_name ?? undefined,
familyName: userinfo.family_name ?? undefined,
middleName: userinfo.middle_name ?? undefined,
nickname: userinfo.nickname ?? undefined,
displayName: userinfo.preferred_username ?? undefined,
pictureUrl: userinfo.picture ?? undefined,
gender: userinfo.gender ?? undefined,
birthdate: userinfo.birthdate ?? undefined,
timeZone: userinfo.zoneinfo ?? undefined,
locale: userinfo.locale ?? undefined,
updatedAt: userinfo.updated_at ?? undefined,
// Email scope
email: userinfo.email ?? undefined,
emailVerified: userinfo.email_verified ?? undefined,
// Phone scope
phoneNumber: userinfo.phone_number ?? undefined,
phoneNumberVerified: userinfo.phone_number_verified ?? undefined,
// Roles scope
// eslint-disable-next-line @typescript-eslint/no-explicit-any
roles: userinfo.roles?.map((role) => {
return {
id: role.id,
name: role.name,
displayName: role.display_name || role.displayName,
};
}),
// Custom claims
customClaims: userinfo.custom_claims,
};
}
}
exports.WristbandService = WristbandService;