@authlink/client
Version:
Official client SDK for integrating with the Authlink Identity Provider
214 lines (213 loc) • 9.5 kB
JavaScript
import { generateRandomString, createPkcePair, validateIdToken } from "./utils";
import { StateMismatchError, SilentAuthorizationError, CodeVerifierMissingError, TokenExchangeError, NonceMissingError, IdTokenMissingError } from "./errors";
export class IdentityProviderClient {
constructor(baseUrl, clientId, cleanupDelayMs = 5000) {
// Key names for storing values in session storage
this.codeVerifierKey = "__code_verifier";
this.stateKey = "__state";
this.nonceKey = "__nonce";
this.baseUrl = baseUrl;
this.clientId = clientId;
this.cleanupDelayMs = Math.max(1000, cleanupDelayMs);
}
/**
* Initiates the authorization process by redirecting the user to the authorization server.
* @param options The authorization options.
* @returns A promise that resolves when the authorization process is initiated.
* */
async authorize(options = {}) {
let state = options.state;
let nonce = options.nonce;
let codeChallenge = options.codeChallenge;
let codeVerifier;
// If state, nonce, or codeChallenge are not provided, generate them
if (!state)
state = generateRandomString(32);
if (!nonce)
nonce = generateRandomString(32);
if (!codeChallenge) {
const { codeVerifier: verifier, codeChallenge: challenge } = await createPkcePair();
codeChallenge = challenge;
codeVerifier = verifier;
}
sessionStorage.setItem(this.stateKey, state);
sessionStorage.setItem(this.nonceKey, nonce);
if (codeVerifier)
sessionStorage.setItem(this.codeVerifierKey, codeVerifier);
// Construct the authorization URL
const params = new URLSearchParams({
responseType: options.responseType ?? "code",
responseMode: options.responseMode ?? "query",
prompt: options.prompt ?? "login",
clientId: options.clientId ?? this.clientId,
redirectUri: options.redirectUri ?? window.location.origin,
scope: options.scope ?? "openid",
state,
nonce,
codeChallenge,
codeChallengeMethod: "S256"
});
window.location.href = `${this.baseUrl}/connect/authorize?${params.toString()}`;
}
/**
* Initiates the silent authorization process by creating an invisible iframe.
* @param options The authorization options.
* @returns A promise that resolves to the authorization code.
* */
async silentAuthorize(options = {}) {
return new Promise(async (resolve, reject) => {
let state = options.state;
let nonce = options.nonce;
let codeChallenge = options.codeChallenge;
let codeVerifier;
// If state, nonce, or codeChallenge are not provided, generate them
if (!state)
state = generateRandomString(32);
if (!nonce)
nonce = generateRandomString(32);
if (!codeChallenge) {
const { codeVerifier: verifier, codeChallenge: challenge } = await createPkcePair();
codeChallenge = challenge;
codeVerifier = verifier;
}
sessionStorage.setItem(this.stateKey, state);
sessionStorage.setItem(this.nonceKey, nonce);
if (codeVerifier)
sessionStorage.setItem(this.codeVerifierKey, codeVerifier);
// Construct the authorization URL
const params = new URLSearchParams({
responseType: options.responseType ?? "code",
responseMode: options.responseMode ?? "web_message",
prompt: options.prompt ?? "none",
clientId: options.clientId ?? this.clientId,
redirectUri: options.redirectUri ?? window.location.origin,
scope: options.scope ?? "openid",
state,
nonce,
codeChallenge,
codeChallengeMethod: "S256"
});
// Create an invisible iframe to initiate the authorization process
const iframe = document.createElement("iframe");
iframe.style.display = "none";
iframe.src = `${this.baseUrl}/connect/authorize?${params.toString()}`;
document.body.appendChild(iframe);
const handler = (event) => {
// Check if the message is from the expected origin
if (event.origin !== this.baseUrl)
return;
// Check if the message contains the expected data
const { data } = event;
if (data.type !== "authorization_response")
return;
// Remove the iframe and event listener
window.removeEventListener("message", handler);
document.body.removeChild(iframe);
if (data.error)
reject(new SilentAuthorizationError(data.error, data.errorDescription));
else if (data.code)
resolve(data.code);
else
reject();
};
window.addEventListener("message", handler);
});
}
/**
* Checks if the state returned from the authorization server matches the stored state
* @param state The state returned from the authorization server
* @returns True if the state is valid, false otherwise
* */
isValidState(state) {
const storedState = sessionStorage.getItem(this.stateKey);
return state === storedState;
}
/**
* Logs out the user by redirecting to the identity provider's logout endpoint
* @param options The logout options.
* */
logout(options = {}) {
let state = options.state;
if (!state)
state = generateRandomString(32);
sessionStorage.setItem(this.stateKey, state);
const params = new URLSearchParams({
clientId: options.clientId ?? this.clientId,
postLogoutRedirectUri: options.postLogoutRedirectUri ?? window.location.origin,
state
});
if (options.idTokenHint)
params.append("idTokenHint", options.idTokenHint);
window.location.href = `${this.baseUrl}/connect/logout?${params.toString()}`;
}
/**
* Exchanges the authorization code or refresh token for new tokens
* @param options The token exchange options.
* @returns A promise that resolves to the token response.
* */
async token(options = {}) {
// Create the request body
let request = {
grantType: options.grantType ?? "authorization_code",
clientId: options.clientId ?? this.clientId,
redirectUri: options.redirectUri ?? window.location.origin
};
if (request.grantType === "authorization_code") {
// Get the code verifier from the session storage
const codeVerifier = sessionStorage.getItem(this.codeVerifierKey);
if (!codeVerifier)
throw new CodeVerifierMissingError();
// Get the state from the cookie
const state = options.state
?? new URLSearchParams(window.location.search).get("state")
?? "";
if (options.validateState && !this.isValidState(state))
throw new StateMismatchError();
// Add the code and code verifier to the request
request.code = options.code
?? new URLSearchParams(window.location.search).get("code")
?? "";
request.codeVerifier = codeVerifier;
}
else if (request.grantType === "refresh_token")
request.refreshToken = options.refreshToken ?? "";
// If a token exchange handler is provided, use it to get the token
// Otherwise, make a request to the token endpoint
let response;
if (options.tokenExchangeHandler)
response = await options.tokenExchangeHandler(request);
else {
const res = await fetch(`${this.baseUrl}/connect/token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Client-Id": this.clientId
},
credentials: "include",
body: new URLSearchParams({ ...request })
});
if (!res.ok) {
const error = await res.text();
throw new TokenExchangeError(res.status, error);
}
response = await res.json();
}
// Validate the nonce
if (request.grantType === "authorization_code") {
const nonce = sessionStorage.getItem(this.nonceKey);
if (!nonce)
throw new NonceMissingError();
const idToken = response.idToken;
if (!idToken)
throw new IdTokenMissingError();
// Validate the ID token
await validateIdToken(idToken, this.baseUrl, this.clientId, nonce);
setTimeout(() => {
sessionStorage.removeItem(this.stateKey);
sessionStorage.removeItem(this.codeVerifierKey);
sessionStorage.removeItem(this.nonceKey);
}, this.cleanupDelayMs);
}
return response;
}
}