UNPKG

@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
"use strict"; 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;