UNPKG

@shane32/msoauth

Version:

A React library for Azure AD authentication with PKCE (Proof Key for Code Exchange) flow support. This library provides a secure and easy-to-use solution for implementing Azure AD authentication in React applications, with support for both API and Microso

677 lines (676 loc) 33.4 kB
"use strict"; var __assign = (this && this.__assign) || function () { __assign = Object.assign || function(t) { for (var s, i = 1, n = arguments.length; i < n; i++) { s = arguments[i]; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; } return t; }; return __assign.apply(this, arguments); }; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __generator = (this && this.__generator) || function (thisArg, body) { var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; function verb(n) { return function (v) { return step([n, v]); }; } function step(op) { if (f) throw new TypeError("Generator is already executing."); while (g && (g = 0, op[0] && (_ = 0)), _) try { if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; if (y = 0, t) op = [op[0] & 2, t.value]; switch (op[0]) { case 0: case 1: t = op; break; case 4: _.label++; return { value: op[1], done: false }; case 5: _.label++; y = op[1]; op = [0]; continue; case 7: op = _.ops.pop(); _.trys.pop(); continue; default: if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } if (t[2]) _.ops.pop(); _.trys.pop(); continue; } op = body.call(thisArg, _); } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; } }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); /* eslint-disable @typescript-eslint/naming-convention */ var AuthManager_helpers_1 = require("./AuthManager.helpers"); var OpenIDConfigurationManager_1 = __importDefault(require("./OpenIDConfigurationManager")); /** * Manages OAuth authentication flow, token handling, and user authorization. * Implements PKCE (Proof Key for Code Exchange) for secure authorization code flow. * @template TPolicyNames - Enum type for policy keys */ var AuthManager = /** @class */ (function () { /** * Creates a new instance of AuthManager * @param {AuthManagerConfiguration} config - Configuration object for the AuthManager */ function AuthManager(config) { this.tokenInfo = null; this.refreshPromise = null; this.eventListeners = new Map(); this.scopeSets = new Map(); this.userInfo = null; if (!config.redirectUri.startsWith("/")) { throw new Error('redirectUri must start with "/"'); } if (config.logoutRedirectUri && !config.logoutRedirectUri.startsWith("/")) { throw new Error('logoutRedirectUri must start with "/"'); } // Default ID to "default" if not provided (for backward compatibility) this.id = config.id || "default"; // Initialize provider-specific storage keys var keyId = config.id ? "_".concat(config.id) : ""; this.tokenKey = "auth_tokens".concat(keyId); this.verifierKey = "auth_pkce_verifier".concat(keyId); this.stateKey = "auth_state".concat(keyId); this.originalUrlKey = "auth_original_url".concat(keyId); this.clientId = config.clientId; this.navigateCallback = config.navigateCallback; this.absoluteRedirectUri = "".concat(window.location.origin).concat(config.redirectUri); this.absoluteLogoutRedirectUri = config.logoutRedirectUri ? "".concat(window.location.origin).concat(config.logoutRedirectUri) : undefined; this.policies = config.policies || {}; // Initialize scopes as requested this.defaultScopes = config.scopes; this.scopeSets.set("default", this.defaultScopes); // Initialize allScopes with default scopes var allScopesList = this.defaultScopes.split(" ").filter(function (s) { return s.trim() !== ""; }); // Add additional scope sets from config if (config.scopeSets) { for (var _i = 0, _a = config.scopeSets; _i < _a.length; _i++) { var scopeSet = _a[_i]; this.scopeSets.set(scopeSet.name, scopeSet.scopes); // Add unique scopes to allScopesList var scopesArray = scopeSet.scopes.split(" ").filter(function (s) { return s.trim() !== ""; }); for (var _b = 0, scopesArray_1 = scopesArray; _b < scopesArray_1.length; _b++) { var scope = scopesArray_1[_b]; if (!allScopesList.includes(scope)) { allScopesList.push(scope); } } } } // Set allScopes as a space-separated string of all unique scopes this.allScopes = allScopesList.join(" "); this.configManager = new OpenIDConfigurationManager_1.default(config.authority); // Try to load tokens from storage var stored = localStorage.getItem(this.tokenKey); if (stored) { var parsedToken = JSON.parse(stored); // Convert from older versions to version 3 if needed this.tokenInfo = (0, AuthManager_helpers_1.convertTokenInfoToV3)(parsedToken); // Initialize userInfo from stored token if (this.tokenInfo && this.tokenInfo.version === 3) { this.userInfo = (0, AuthManager_helpers_1.extractUserInfo)(this.tokenInfo.idToken); } } } // ====== Event handling ====== /** * Registers an event listener for authentication events * @param {AuthEventType} event - Type of event to listen for * @param {AuthEventListener} listener - Callback function to execute */ AuthManager.prototype.addEventListener = function (event, listener) { if (!this.eventListeners.has(event)) { this.eventListeners.set(event, new Set()); } this.eventListeners.get(event).add(listener); }; /** * Removes an event listener for authentication events * @param {AuthEventType} event - Type of event to stop listening for * @param {AuthEventListener} listener - Callback function to remove */ AuthManager.prototype.removeEventListener = function (event, listener) { var _a; (_a = this.eventListeners.get(event)) === null || _a === void 0 ? void 0 : _a.delete(listener); }; /** * Triggers an authentication event * @param {AuthEventType} event - Type of event to emit */ AuthManager.prototype.emitEvent = function (event) { var _a; (_a = this.eventListeners.get(event)) === null || _a === void 0 ? void 0 : _a.forEach(function (listener) { return listener(); }); }; // ====== Authentication methods ====== /** * When logged in, refreshes the access token if it's expired, otherwise logs out */ AuthManager.prototype.autoLogin = function () { return __awaiter(this, void 0, void 0, function () { var err_1; return __generator(this, function (_a) { switch (_a.label) { case 0: if (!this.tokenInfo) return [3 /*break*/, 4]; _a.label = 1; case 1: _a.trys.push([1, 3, , 4]); // if the token has expired, refresh the tokens return [4 /*yield*/, this.getAccessToken()]; case 2: // if the token has expired, refresh the tokens _a.sent(); return [3 /*break*/, 4]; case 3: err_1 = _a.sent(); console.warn("Unable to refresh cached tokens", err_1); // Failed to refresh token - user needs to login manually this.localLogout(); return [3 /*break*/, 4]; case 4: return [2 /*return*/]; } }); }); }; /** * Checks if the user is currently authenticated * @returns {boolean} True if user has valid refresh tokens */ AuthManager.prototype.isAuthenticated = function () { return this.tokenInfo !== null && this.tokenInfo.refreshToken !== undefined && this.tokenInfo.refreshToken !== ""; }; /** * Initiates the OAuth login flow with PKCE * Redirects to the OAuth provider's authorization endpoint * @param {string} [path] - Optional path to redirect after login */ AuthManager.prototype.login = function (path) { return __awaiter(this, void 0, void 0, function () { var pkce, config, state, params; return __generator(this, function (_a) { switch (_a.label) { case 0: // Store current URL before redirecting localStorage.setItem(this.originalUrlKey, path || (0, AuthManager_helpers_1.getCurrentRelativeUrl)()); return [4 /*yield*/, (0, AuthManager_helpers_1.generatePKCECodes)()]; case 1: pkce = _a.sent(); return [4 /*yield*/, this.configManager.getConfiguration()]; case 2: config = _a.sent(); // Store verifier for later use localStorage.setItem(this.verifierKey, pkce.codeVerifier); state = (0, AuthManager_helpers_1.generateState)(); localStorage.setItem(this.stateKey, state); params = this.generateLoginParams(pkce.codeChallenge, state); window.location.href = "".concat(config.authorization_endpoint, "?").concat(params.toString()); return [2 /*return*/]; } }); }); }; /** * Generates the parameters for the login request * @param {string} codeChallenge - The PKCE code challenge * @param {string} state - The state parameter for CSRF protection * @returns {URLSearchParams} The parameters for the login request * @protected */ AuthManager.prototype.generateLoginParams = function (codeChallenge, state) { return new URLSearchParams({ client_id: this.clientId, redirect_uri: this.absoluteRedirectUri, response_type: "code", scope: this.allScopes, state: state, code_challenge_method: "S256", code_challenge: codeChallenge, }); }; /** * Handles the OAuth redirect callback * Exchanges the authorization code for tokens * If multiple scope sets exist, immediately refreshes tokens to get tokens for all scope sets * If only one scope set exists, uses the returned tokens directly * @throws {Error} If authorization code is missing or invalid */ AuthManager.prototype.handleRedirect = function () { return __awaiter(this, void 0, void 0, function () { var queryParams, code, codeVerifier, storedState, params, response, _a, rawData, data, originalUrl; return __generator(this, function (_b) { switch (_b.label) { case 0: queryParams = new URLSearchParams(window.location.search); code = queryParams.get("code"); if (!code) { throw new Error("No authorization code found"); } codeVerifier = localStorage.getItem(this.verifierKey); if (!codeVerifier) { throw new Error("No code verifier found"); } storedState = localStorage.getItem(this.stateKey); if (!storedState || storedState !== queryParams.get("state")) { throw new Error("Invalid state parameter"); } // Clean up state localStorage.removeItem(this.stateKey); params = this.generateRedirectParams(code, codeVerifier); // Clean up verifier localStorage.removeItem(this.verifierKey); _a = fetch; return [4 /*yield*/, this.getTokenEndpointUrl("authorization_code")]; case 1: return [4 /*yield*/, _a.apply(void 0, [_b.sent(), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: params.toString(), }])]; case 2: response = _b.sent(); if (!response.ok) { throw new Error("Failed to get tokens"); } return [4 /*yield*/, response.json()]; case 3: rawData = _b.sent(); data = this.parseTokenResponse(rawData); if (!(this.scopeSets.size === 1)) return [3 /*break*/, 4]; // If only one scope set exists, use the returned tokens directly this.tokenInfo = { version: 3, refreshToken: data.refresh_token, idToken: data.id_token || "", idTokenExpiresAt: data.id_token ? (0, AuthManager_helpers_1.extractTokenExpiration)(data.id_token) : 0, accessTokens: { default: { token: data.access_token, expiresAt: Date.now() + data.expires_in * 1000, }, }, }; // Update user info from ID token if (data.id_token) { this.userInfo = (0, AuthManager_helpers_1.extractUserInfo)(data.id_token); } // Save tokens to local storage localStorage.setItem(this.tokenKey, JSON.stringify(this.tokenInfo)); this.emitEvent("tokensChanged"); return [3 /*break*/, 6]; case 4: // If multiple scope sets exist, refresh tokens to get tokens for all scope sets return [4 /*yield*/, this.refreshTokens(data.refresh_token)]; case 5: // If multiple scope sets exist, refresh tokens to get tokens for all scope sets _b.sent(); _b.label = 6; case 6: this.emitEvent("login"); originalUrl = localStorage.getItem(this.originalUrlKey); if (originalUrl) { localStorage.removeItem(this.originalUrlKey); this.navigateCallback(originalUrl); } return [2 /*return*/]; } }); }); }; /** * Initiates the logout process * Clears local tokens and optionally redirects to the OAuth provider's logout endpoint * @param {string} [path] - Optional path to redirect after logout */ AuthManager.prototype.logout = function (path) { return __awaiter(this, void 0, void 0, function () { var config, params; return __generator(this, function (_a) { switch (_a.label) { case 0: // Store current URL before redirecting localStorage.setItem(this.originalUrlKey, path || (0, AuthManager_helpers_1.getCurrentRelativeUrl)()); // First perform local logout this.localLogout(); return [4 /*yield*/, this.configManager.getConfiguration()]; case 1: config = _a.sent(); if (config.end_session_endpoint) { params = this.absoluteLogoutRedirectUri ? new URLSearchParams({ client_id: this.clientId, post_logout_redirect_uri: this.absoluteLogoutRedirectUri, }) : new URLSearchParams({ client_id: this.clientId, }); window.location.href = "".concat(config.end_session_endpoint, "?").concat(params.toString()); } return [2 /*return*/]; } }); }); }; /** * Handles the redirect after logout * Restores the original URL or navigates to home */ AuthManager.prototype.handleLogoutRedirect = function () { // Restore original URL if it exists var originalUrl = localStorage.getItem(this.originalUrlKey); if (originalUrl) { localStorage.removeItem(this.originalUrlKey); this.navigateCallback(originalUrl); } else { this.navigateCallback("/"); } }; /** * Performs local logout by clearing tokens and cache without redirecting to the authentication provider */ AuthManager.prototype.localLogout = function () { if (!this.tokenInfo && !this.userInfo) { return; } this.tokenInfo = null; this.userInfo = null; this.configManager.clearCache(); localStorage.removeItem(this.tokenKey); localStorage.removeItem(this.verifierKey); localStorage.removeItem(this.stateKey); this.emitEvent("logout"); this.emitEvent("tokensChanged"); }; /** * Gets a valid access token for the specified scope set * @param {string} [scopeSetName="default"] - The name of the scope set to get the token for * @returns {Promise<string>} A valid access token * @throws {Error} If not authenticated, token refresh fails, or scope set doesn't exist */ AuthManager.prototype.getAccessToken = function () { return __awaiter(this, arguments, void 0, function (scopeSetName) { var token; var _a; if (scopeSetName === void 0) { scopeSetName = "default"; } return __generator(this, function (_b) { switch (_b.label) { case 0: if (!this.tokenInfo) { throw new Error("Not authenticated"); } if (!this.scopeSets.has(scopeSetName)) { throw new Error("Scope set '".concat(scopeSetName, "' does not exist")); } if (!this.isTokenExpired()) return [3 /*break*/, 2]; return [4 /*yield*/, this.refreshTokens()]; case 1: _b.sent(); _b.label = 2; case 2: token = (_a = this.tokenInfo.accessTokens[scopeSetName]) === null || _a === void 0 ? void 0 : _a.token; if (!token) { throw new Error("No token available for scope set '".concat(scopeSetName, "'")); } return [2 /*return*/, token]; } }); }); }; /** * Gets a valid ID token, refreshing if necessary * @returns {Promise<string>} A valid ID token * @throws {Error} If not authenticated or token refresh fails */ AuthManager.prototype.getIdToken = function () { return __awaiter(this, void 0, void 0, function () { return __generator(this, function (_a) { switch (_a.label) { case 0: if (!this.tokenInfo) { throw new Error("Not authenticated"); } if (!this.isIdTokenExpired()) return [3 /*break*/, 2]; return [4 /*yield*/, this.refreshTokens()]; case 1: _a.sent(); _a.label = 2; case 2: if (!this.tokenInfo.idToken) { throw new Error("No ID token available"); } return [2 /*return*/, this.tokenInfo.idToken]; } }); }); }; /** * Refreshes all access tokens using the refresh token, * allowing simultaneous calls to avoid multiple refreshes. * A single call to refreshTokensInternal will be made, if needed. * @param {string} [refreshToken] - Optional refresh token to use * @throws {Error} If token refresh fails */ AuthManager.prototype.refreshTokens = function (refreshToken) { return __awaiter(this, void 0, void 0, function () { return __generator(this, function (_a) { switch (_a.label) { case 0: if (!this.refreshPromise) return [3 /*break*/, 2]; return [4 /*yield*/, this.refreshPromise]; case 1: _a.sent(); return [2 /*return*/]; case 2: this.refreshPromise = this.refreshTokensInternal(refreshToken); _a.label = 3; case 3: _a.trys.push([3, , 5, 6]); return [4 /*yield*/, this.refreshPromise]; case 4: _a.sent(); return [3 /*break*/, 6]; case 5: this.refreshPromise = null; return [7 /*endfinally*/]; case 6: return [2 /*return*/]; } }); }); }; /** * Refreshes all access tokens using the refresh token * @param {string} [refreshToken] - Optional refresh token to use * @throws {Error} If token refresh fails */ AuthManager.prototype.refreshTokensInternal = function (refreshToken) { return __awaiter(this, void 0, void 0, function () { var currentRefreshToken, newTokenInfo, scopeEntries, i, _a, scopeSetName, scopes, params, response, _b, rawData, data, idTokenExpiresAt, userInfo; var _c; return __generator(this, function (_d) { switch (_d.label) { case 0: currentRefreshToken = refreshToken !== null && refreshToken !== void 0 ? refreshToken : (_c = this.tokenInfo) === null || _c === void 0 ? void 0 : _c.refreshToken; if (!currentRefreshToken) { console.warn("No refresh token available during token refresh; logging out"); this.localLogout(); throw new Error("No refresh token available"); } // Initialize token info if needed if (!this.tokenInfo) { this.tokenInfo = { version: 3, refreshToken: currentRefreshToken, idToken: "", idTokenExpiresAt: 0, accessTokens: {}, }; } newTokenInfo = __assign(__assign({}, this.tokenInfo), { accessTokens: __assign({}, this.tokenInfo.accessTokens) }); scopeEntries = Array.from(this.scopeSets.entries()); i = 0; _d.label = 1; case 1: if (!(i < scopeEntries.length)) return [3 /*break*/, 6]; _a = scopeEntries[i], scopeSetName = _a[0], scopes = _a[1]; if (!scopes || scopes.trim() === "") return [3 /*break*/, 5]; params = new URLSearchParams({ grant_type: "refresh_token", client_id: this.clientId, refresh_token: currentRefreshToken, scope: scopes, }); _b = fetch; return [4 /*yield*/, this.getTokenEndpointUrl("refresh_token")]; case 2: return [4 /*yield*/, _b.apply(void 0, [_d.sent(), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: params.toString(), }])]; case 3: response = _d.sent(); if (!response.ok) { throw new Error("Failed to refresh token for scope set '".concat(scopeSetName, "'")); } return [4 /*yield*/, response.json()]; case 4: rawData = _d.sent(); data = this.parseTokenResponse(rawData); // Update the refresh token for subsequent requests only if a new one is provided // This preserves the existing token if the provider doesn't rotate refresh tokens if (!!data.refresh_token) { currentRefreshToken = data.refresh_token; } // Update the token info newTokenInfo.accessTokens[scopeSetName] = { token: data.access_token, expiresAt: Date.now() + Number(data.expires_in) * 1000, }; // Update ID token and its expiration if present if (data.id_token) { idTokenExpiresAt = (0, AuthManager_helpers_1.extractTokenExpiration)(data.id_token); newTokenInfo.idToken = data.id_token; newTokenInfo.idTokenExpiresAt = idTokenExpiresAt; } _d.label = 5; case 5: i++; return [3 /*break*/, 1]; case 6: userInfo = newTokenInfo.idToken ? (0, AuthManager_helpers_1.extractUserInfo)(newTokenInfo.idToken) : null; // Set tokenInfo and userInfo atomically (to avoid inconsistent state) // Update the tokens, and update the refresh token with the latest one this.tokenInfo = __assign(__assign({}, newTokenInfo), { refreshToken: currentRefreshToken }); // Update user info from ID token if (userInfo) { this.userInfo = userInfo; } // Save tokens to local storage localStorage.setItem(this.tokenKey, JSON.stringify(this.tokenInfo)); this.emitEvent("tokensChanged"); return [2 /*return*/]; } }); }); }; /** * Checks if any access token is expired * @returns {boolean} True if any token is expired or close to expiring */ AuthManager.prototype.isTokenExpired = function () { if (!this.tokenInfo || !this.tokenInfo.accessTokens) return true; var now = Date.now(); var expirationBuffer = 5 * 60 * 1000; // 5 minutes // Check if any token is expired or missing for (var _i = 0, _a = Array.from(this.scopeSets.keys()); _i < _a.length; _i++) { var scope = _a[_i]; var tokenInfo = this.tokenInfo.accessTokens[scope]; if (!tokenInfo || tokenInfo.expiresAt - now < expirationBuffer) { return true; } } return false; }; /** * Checks if the ID token is expired * @returns {boolean} True if ID token is expired or close to expiring */ AuthManager.prototype.isIdTokenExpired = function () { if (!this.tokenInfo || !this.tokenInfo.idTokenExpiresAt) return true; var now = Date.now(); var expirationBuffer = 5 * 60 * 1000; // 5 minutes return this.tokenInfo.idTokenExpiresAt - now < expirationBuffer; }; /** * Checks if the user has a specific policy permission * @param {TPolicyNames} policy - The policy to check * @returns {boolean} True if user has the specified policy permission */ AuthManager.prototype.can = function (policy) { var _a, _b, _c; return this.userInfo ? ((_c = (_b = (_a = this.policies)[policy]) === null || _b === void 0 ? void 0 : _b.call(_a, this.userInfo.roles)) !== null && _c !== void 0 ? _c : false) : false; }; /** * Gets the token endpoint URL for a specific grant type * @param {string} grantType - The OAuth grant type (e.g., "authorization_code", "refresh_token") * @returns {Promise<string>} The URL to use for token requests * @protected */ AuthManager.prototype.getTokenEndpointUrl = function (grantType) { return __awaiter(this, void 0, void 0, function () { var config; return __generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, this.configManager.getConfiguration()]; case 1: config = _a.sent(); return [2 /*return*/, config.token_endpoint]; } }); }); }; /** * Generates the parameters for the token request during redirect handling * @param {string} code - The authorization code from the OAuth provider * @param {string} codeVerifier - The PKCE code verifier * @returns {URLSearchParams} The parameters for the token request * @protected */ AuthManager.prototype.generateRedirectParams = function (code, codeVerifier) { return new URLSearchParams({ grant_type: "authorization_code", client_id: this.clientId, code_verifier: codeVerifier, code: code, redirect_uri: this.absoluteRedirectUri, }); }; /** * Parses the token response from the OAuth provider * @param {TokenResponse} response - The raw token response from the OAuth provider * @returns {TokenResponse} The parsed token response * @protected */ AuthManager.prototype.parseTokenResponse = function (response) { // By default, just return the response as-is return response; }; return AuthManager; }()); exports.default = AuthManager;