UNPKG

@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
// 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, }), };