@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
672 lines (671 loc) • 33 kB
JavaScript
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 };
}
};
/* eslint-disable @typescript-eslint/naming-convention */
import { extractUserInfo, extractTokenExpiration, convertTokenInfoToV3, generatePKCECodes, generateState, getCurrentRelativeUrl, } from "./AuthManager.helpers";
import OpenIDConfigurationManager from "./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(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 = convertTokenInfoToV3(parsedToken);
// Initialize userInfo from stored token
if (this.tokenInfo && this.tokenInfo.version === 3) {
this.userInfo = 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 || getCurrentRelativeUrl());
return [4 /*yield*/, 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 = 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 ? 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 = 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 || 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 = 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 ? 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;
}());
export default AuthManager;