@ssktechnologies/awsforge
Version:
Enterprise-grade AWS Cognito authentication toolkit for seamless user management, registration, login, and password recovery with JWT token handling
369 lines (368 loc) • 14.9 kB
JavaScript
// src/services/cognito.ts
import crypto from 'crypto';
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
import { PackageConfig } from '../config/packageConfig.js';
import { CognitoIdentityProviderClient, SignUpCommand, ConfirmSignUpCommand, InitiateAuthCommand, ForgotPasswordCommand, ConfirmForgotPasswordCommand, GetUserCommand, ChangePasswordCommand, DeleteUserCommand, ResendConfirmationCodeCommand, RevokeTokenCommand, AuthFlowType, } from "@aws-sdk/client-cognito-identity-provider";
export class CognitoService {
config;
client;
jwksClient;
constructor(userConfig = {}) {
this.config = new PackageConfig({
...userConfig,
validateCustomAttributes: true,
});
// Initialize AWS client with package config
this.client = new CognitoIdentityProviderClient({
region: this.config.cognito.region,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY,
secretAccessKey: process.env.AWS_SECRET_KEY,
},
});
// Initialize JWKS client for token verification
this.jwksClient = jwksClient({
jwksUri: `https://cognito-idp.${this.config.cognito.region}.amazonaws.com/${this.config.cognito.userPoolId}/.well-known/jwks.json`,
cache: true,
cacheMaxAge: 3600000, // 1 hour
cacheMaxEntries: 5,
});
}
generateSecretHash(username) {
if (!this.config.cognito.clientSecret) {
return undefined;
}
const message = username + this.config.cognito.clientId;
return crypto
.createHmac('sha256', this.config.cognito.clientSecret)
.update(message)
.digest('base64');
}
validateCustomAttributes(customAttributes) {
if (!this.config.validateCustomAttributes) {
return customAttributes;
}
if (!this.config.allowedCustomAttributes || this.config.allowedCustomAttributes.length === 0) {
console.warn('No custom attributes are configured. Skipping custom attributes.');
return {};
}
const validAttributes = {};
const invalidAttributes = [];
for (const [key, value] of Object.entries(customAttributes)) {
if (this.config.allowedCustomAttributes.includes(key)) {
validAttributes[key] = value;
}
else {
invalidAttributes.push(key);
}
}
if (invalidAttributes.length > 0) {
console.warn(`Invalid custom attributes ignored: ${invalidAttributes.join(', ')}`);
console.warn(`Allowed custom attributes: ${this.config.allowedCustomAttributes.join(', ')}`);
}
return validAttributes;
}
buildUserAttributes(registrationData) {
const attributes = [];
if (registrationData.email) {
attributes.push({ Name: 'email', Value: registrationData.email });
}
if (registrationData.username) {
attributes.push({ Name: 'preferred_username', Value: registrationData.username });
}
if (registrationData.firstName) {
attributes.push({ Name: 'given_name', Value: registrationData.firstName });
}
if (registrationData.lastName) {
attributes.push({ Name: 'family_name', Value: registrationData.lastName });
}
if (registrationData.phoneNumber) {
attributes.push({ Name: 'phone_number', Value: registrationData.phoneNumber });
}
if (registrationData.customAttributes) {
const validCustomAttributes = this.validateCustomAttributes(registrationData.customAttributes);
attributes.push(...Object.entries(validCustomAttributes).map(([key, value]) => ({
Name: `custom:${key}`,
Value: value,
})));
}
return attributes;
}
// Token Verification Methods
async verifyToken(token, skipAudienceCheck = false) {
try {
const decoded = jwt.decode(token, { complete: true });
if (!decoded || !decoded.header || !decoded.payload) {
return {
isValid: false,
error: 'Invalid token format',
};
}
// Get the signing key
const key = await this.jwksClient.getSigningKey(decoded.header.kid);
const signingKey = key.getPublicKey();
// Verify token with or without audience check
const verifyOptions = {
issuer: `https://cognito-idp.${this.config.cognito.region}.amazonaws.com/${this.config.cognito.userPoolId}`,
};
// Only check audience for ID tokens, not access tokens
if (!skipAudienceCheck) {
verifyOptions.audience = this.config.cognito.clientId;
}
const payload = jwt.verify(token, signingKey, verifyOptions);
// Check if token is expired
const currentTime = Math.floor(Date.now() / 1000);
if (payload.exp < currentTime) {
return {
isValid: false,
error: 'Token expired',
decoded: payload,
};
}
return {
isValid: true,
decoded: payload,
};
}
catch (error) {
return {
isValid: false,
error: error instanceof Error ? error.message : 'Token verification failed',
};
}
}
async verifyAccessToken(accessToken) {
// Skip audience check for access tokens as they use User Pool ID as audience
const result = await this.verifyToken(accessToken, true);
if (result.isValid && result.decoded?.token_use && result.decoded.token_use !== 'access') {
return {
isValid: false,
error: 'Token is not an access token',
decoded: result.decoded,
};
}
return result;
}
async verifyIdToken(idToken) {
// Use audience check for ID tokens
const result = await this.verifyToken(idToken, false);
if (result.isValid && result.decoded?.token_use && result.decoded.token_use !== 'id') {
return {
isValid: false,
error: 'Token is not an ID token',
decoded: result.decoded,
};
}
return result;
}
// Get user profile from token
async getUserFromToken(accessToken) {
const command = new GetUserCommand({
AccessToken: accessToken,
});
const response = await this.client.send(command);
const userProfile = {
username: response.Username,
attributes: {},
};
response.UserAttributes?.forEach(attr => {
if (attr.Name && attr.Value) {
userProfile.attributes[attr.Name] = attr.Value;
}
});
return userProfile;
}
// Helper method to extract username from JWT token - IMPROVED
extractUsernameFromToken(refreshToken) {
try {
const decoded = jwt.decode(refreshToken);
// For Cognito refresh tokens, we need to use the SUB (subject) for SecretHash
// This is a poorly documented requirement from AWS Cognito
return decoded?.username ||
decoded?.email ||
decoded?.sub ||
decoded?.preferred_username ||
undefined;
}
catch (error) {
console.error('Error extracting username from token:', error);
return undefined;
}
}
// Helper method to get SUB from access token
getSubFromAccessToken(accessToken) {
try {
const decoded = jwt.decode(accessToken);
return decoded?.sub;
}
catch (error) {
console.error('Error extracting SUB from access token:', error);
return undefined;
}
}
// Refresh tokens - FIXED VERSION with SUB handling
async refreshTokens(refreshToken, username, accessToken) {
const authParameters = {
REFRESH_TOKEN: refreshToken,
};
// If client secret is configured, we need SECRET_HASH
if (this.config.cognito.clientSecret) {
let usernameForHash = username;
// Try to get SUB from access token first (most reliable for refresh)
if (!usernameForHash && accessToken) {
try {
const userProfile = await this.getUserFromToken(accessToken);
usernameForHash = userProfile.attributes.sub || userProfile.username;
console.log('Using SUB from user profile for SECRET_HASH:', usernameForHash);
}
catch (error) {
console.warn('Could not get user profile from access token:', error);
// Fall back to token extraction
usernameForHash = this.getSubFromAccessToken(accessToken);
}
}
// If still no username, try to extract from refresh token
if (!usernameForHash) {
usernameForHash = this.extractUsernameFromToken(refreshToken);
}
if (!usernameForHash) {
throw new Error('Username/SUB is required for refresh token when using client secret. Cannot extract from tokens.');
}
console.log('Using username/SUB for SECRET_HASH:', usernameForHash);
const secretHash = this.generateSecretHash(usernameForHash);
if (secretHash) {
authParameters.SECRET_HASH = secretHash;
}
}
const command = new InitiateAuthCommand({
AuthFlow: AuthFlowType.REFRESH_TOKEN_AUTH,
ClientId: this.config.cognito.clientId,
AuthParameters: authParameters,
});
return this.client.send(command);
}
// Refresh tokens with explicit username (for backwards compatibility)
async refreshTokensWithUsername(refreshToken, username, accessToken) {
return this.refreshTokens(refreshToken, username, accessToken);
}
// Revoke tokens (logout)
async revokeToken(token) {
const command = new RevokeTokenCommand({
ClientId: this.config.cognito.clientId,
Token: token,
});
return this.client.send(command);
}
// Change password
async changePassword({ accessToken, previousPassword, proposedPassword }) {
const command = new ChangePasswordCommand({
AccessToken: accessToken,
PreviousPassword: previousPassword,
ProposedPassword: proposedPassword,
});
return this.client.send(command);
}
// Confirm forgot password
async confirmForgotPassword({ username, confirmationCode, newPassword }) {
const secretHash = this.generateSecretHash(username);
const command = new ConfirmForgotPasswordCommand({
ClientId: this.config.cognito.clientId,
Username: username,
ConfirmationCode: confirmationCode,
Password: newPassword,
...(secretHash && { SecretHash: secretHash }),
});
return this.client.send(command);
}
// Resend confirmation code
async resendConfirmationCode(username) {
const secretHash = this.generateSecretHash(username);
const command = new ResendConfirmationCodeCommand({
ClientId: this.config.cognito.clientId,
Username: username,
...(secretHash && { SecretHash: secretHash }),
});
return this.client.send(command);
}
// Delete user
async deleteUser(accessToken) {
const command = new DeleteUserCommand({
AccessToken: accessToken,
});
return this.client.send(command);
}
// Existing methods remain the same
async registerUser(registrationData) {
try {
const userAttributes = this.buildUserAttributes(registrationData);
console.log('Registering user with attributes:', userAttributes);
const secretHash = this.generateSecretHash(registrationData.email);
const command = new SignUpCommand({
ClientId: this.config.cognito.clientId,
Username: registrationData.email,
Password: registrationData.password,
UserAttributes: userAttributes,
...(secretHash && { SecretHash: secretHash }),
});
const response = await this.client.send(command);
return response;
}
catch (error) {
console.error('Registration error:', error);
throw error;
}
}
async confirmUserRegistration({ username, confirmationCode }) {
const secretHash = this.generateSecretHash(username);
const command = new ConfirmSignUpCommand({
ClientId: this.config.cognito.clientId,
Username: username,
ConfirmationCode: confirmationCode,
...(secretHash && { SecretHash: secretHash }),
});
return this.client.send(command);
}
async loginUser({ username, password }) {
const authParameters = {
USERNAME: username,
PASSWORD: password,
};
const secretHash = this.generateSecretHash(username);
if (secretHash) {
authParameters.SECRET_HASH = secretHash;
}
const command = new InitiateAuthCommand({
AuthFlow: AuthFlowType.USER_PASSWORD_AUTH,
ClientId: this.config.cognito.clientId,
AuthParameters: authParameters,
});
return this.client.send(command);
}
async initiateForgotPassword({ username }) {
const secretHash = this.generateSecretHash(username);
const command = new ForgotPasswordCommand({
ClientId: this.config.cognito.clientId,
Username: username,
...(secretHash && { SecretHash: secretHash }),
});
return this.client.send(command);
}
}
// Export configuration presets
export const CognitoConfigs = {
minimal: (baseConfig) => ({
...baseConfig,
allowedCustomAttributes: [],
validateCustomAttributes: true,
}),
withCustomAttributes: (baseConfig, customAttributes) => ({
...baseConfig,
allowedCustomAttributes: customAttributes,
validateCustomAttributes: true,
}),
permissive: (baseConfig) => ({
...baseConfig,
validateCustomAttributes: false,
}),
};