@joinmeow/cognito-passwordless-auth
Version:
Passwordless authentication with Amazon Cognito: FIDO2 (WebAuthn, support for Passkeys)
402 lines (401 loc) • 17.9 kB
JavaScript
/**
* Copyright Amazon.com, Inc. and its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License"). You
* may not use this file except in compliance with the License. A copy of
* the License is located at
*
* http://aws.amazon.com/apache2.0/
*
* or in the "license" file accompanying this file. This file is
* distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
* ANY KIND, either express or implied. See the License for the specific
* language governing permissions and limitations under the License.
*/
import { configure, getAuthorizeEndpoint, getTokenEndpoint } from "./config.js";
import { generateRandomString, generatePkcePair } from "./oauthUtil.js";
import { parseJwtPayload } from "./util.js";
import { withStorageLock, LockTimeoutError } from "./lock.js";
import { processTokens } from "./common.js";
const STATE_KEY = "cognito_oauth_state";
const PKCE_KEY = "cognito_oauth_pkce";
const OAUTH_IN_PROGRESS_KEY = "cognito_oauth_in_progress";
export async function signInWithRedirect({ provider = "COGNITO", customState, oauthParams, } = {}) {
const cfg = configure();
const { debug } = cfg;
if (!cfg.hostedUi) {
throw new Error("hostedUi configuration missing");
}
const { redirectSignIn, scopes, responseType = "code" } = cfg.hostedUi;
// Get the OAuth authorize endpoint
const authorizeEndpoint = getAuthorizeEndpoint();
debug?.(`Using OAuth authorize endpoint: ${authorizeEndpoint}`);
// Construct full URL from relative URL if needed
let fullRedirectUrl = redirectSignIn;
// Check if the redirectSignIn is relative (doesn't start with http:// or https://)
if (redirectSignIn && !redirectSignIn.match(/^https?:\/\//)) {
debug?.(`Converting relative redirectSignIn "${redirectSignIn}" to absolute URL`);
// Create a URL object using the current location as base
const currentUrl = new URL(cfg.location.href);
// Create a new URL with the current as base and the redirect path as the path
const absoluteUrl = new URL(redirectSignIn, currentUrl.origin);
fullRedirectUrl = absoluteUrl.href;
debug?.(`Converted to absolute URL: ${fullRedirectUrl}`);
}
const stateRandom = generateRandomString(32);
const state = customState
? `${stateRandom}-${btoa(customState)}`
: stateRandom;
const { verifier, challenge, method } = await generatePkcePair();
// Wrap OAuth state storage in a lock to prevent race conditions
const oauthLockKey = `Passwordless.${cfg.clientId}.oauthLock`;
debug?.("🔒 [OAuth] Acquiring lock for OAuth state storage");
try {
await withStorageLock(oauthLockKey, async () => {
await cfg.storage.setItem(STATE_KEY, state);
await cfg.storage.setItem(PKCE_KEY, verifier);
await cfg.storage.setItem(OAUTH_IN_PROGRESS_KEY, "true");
});
}
catch (error) {
if (error instanceof LockTimeoutError) {
debug?.("⏱️ [OAuth] Lock timeout - another OAuth operation in progress");
throw new Error("Another OAuth operation is in progress. Please try again.");
}
throw error;
}
debug?.("✅ [OAuth] OAuth state stored successfully");
const query = {
client_id: cfg.clientId,
redirect_uri: fullRedirectUrl,
response_type: responseType,
scope: (scopes ?? ["openid", "email", "profile"]).join(" "),
state,
identity_provider: provider,
...oauthParams,
};
if (responseType === "code") {
query.code_challenge = challenge;
query.code_challenge_method = method;
}
debug?.(`Initiating OAuth redirect with params: ${JSON.stringify(query)}`);
const qs = new URLSearchParams(query).toString();
const oauthUrl = `${authorizeEndpoint}?${qs}`;
debug?.(`Redirecting to: ${oauthUrl}`);
cfg.location.href = oauthUrl;
}
export async function handleCognitoOAuthCallback() {
const cfg = configure();
const { debug } = cfg;
debug?.("OAuth callback triggered - beginning callback processing");
if (!cfg.hostedUi) {
debug?.("No hostedUi config found, ignoring redirect");
return null;
}
const { redirectSignIn, responseType = "code" } = cfg.hostedUi;
debug?.(`OAuth configuration: redirectSignIn=${redirectSignIn}, responseType=${responseType}`);
// Convert relative redirectSignIn to absolute URL if needed
let fullRedirectUrl = redirectSignIn;
if (redirectSignIn && !redirectSignIn.match(/^https?:\/\//)) {
debug?.(`Converting relative redirectSignIn "${redirectSignIn}" to absolute URL for comparison`);
// Create a URL object using the current location as base
const currentUrl = new URL(cfg.location.href);
// Create a new URL with the current as base and the redirect path as the path
const absoluteUrl = new URL(redirectSignIn, currentUrl.origin);
fullRedirectUrl = absoluteUrl.href;
debug?.(`Converted to absolute URL: ${fullRedirectUrl}`);
}
const inFlight = await cfg.storage.getItem(OAUTH_IN_PROGRESS_KEY);
debug?.(`OAuth in-flight status: ${inFlight}`);
if (inFlight !== "true") {
debug?.("No OAuth flow in progress, ignoring redirect");
return null; // not our redirect
}
// Mark as processing to prevent duplicate handling in concurrent invocations
await cfg.storage.setItem(OAUTH_IN_PROGRESS_KEY, "processing");
const url = new URL(cfg.location.href);
debug?.(`Current URL: ${url.toString()}`);
debug?.(`URL query parameters: ${JSON.stringify(Object.fromEntries(url.searchParams))}`);
const normalize = (u) => {
const { origin, pathname } = new URL(u);
// Remove trailing slash for reliable comparison
const path = pathname.endsWith("/") && pathname !== "/"
? pathname.slice(0, -1)
: pathname;
return origin + path;
};
const normalizedCurrentUrl = normalize(cfg.location.href);
const normalizedRedirectUrl = normalize(fullRedirectUrl);
debug?.(`Normalized URLs - Current: ${normalizedCurrentUrl}, Expected: ${normalizedRedirectUrl}`);
if (normalizedCurrentUrl !== normalizedRedirectUrl) {
debug?.(`URL mismatch, not our expected redirect URL. Ignoring.`);
return null;
}
debug?.("URL matches expected redirect URL, continuing OAuth flow");
const error = url.searchParams.get("error");
if (error) {
const errorDesc = url.searchParams.get("error_description") ?? error;
debug?.(`OAuth error received: ${error}, description: ${errorDesc}`);
await clear();
throw new Error(errorDesc);
}
// For code flow, state is in search params. For implicit flow, it's in hash
let returnedState = url.searchParams.get("state");
if (!returnedState && responseType === "token") {
// Check hash for implicit flow
const hashParams = new URLSearchParams(url.hash.substring(1));
returnedState = hashParams.get("state");
}
debug?.(`Returned state parameter: ${returnedState?.substring(0, 10)}...`);
// Wrap OAuth state validation in a lock
const oauthLockKey = `Passwordless.${cfg.clientId}.oauthLock`;
try {
await withStorageLock(oauthLockKey, async () => {
const storedState = await cfg.storage.getItem(STATE_KEY);
debug?.(`Stored state from browser: ${storedState?.substring(0, 10)}...`);
if (!returnedState || returnedState !== storedState) {
debug?.("OAuth state mismatch - possible CSRF attack or invalidated session");
await clearInternal();
throw new Error("OAuth state mismatch");
}
});
}
catch (error) {
if (error instanceof LockTimeoutError) {
debug?.("⏱️ [OAuth] Lock timeout during state validation");
throw new Error("OAuth operation in progress. Please try again.");
}
throw error;
}
debug?.("OAuth state validation successful");
let tokens;
if (responseType === "code") {
const code = url.searchParams.get("code");
debug?.(`Authorization code received: ${code?.substring(0, 5)}...`);
if (!code) {
debug?.("No authorization code in response");
await clear();
throw new Error("Authorization code missing");
}
debug?.("Proceeding to exchange code for tokens");
tokens = await exchangeCodeForTokens(code);
}
else {
// implicit flow: tokens are in hash
debug?.("Using implicit flow, extracting tokens from URL hash");
const hash = url.hash.substring(1);
const params = new URLSearchParams(hash);
debug?.(`Hash parameters: ${JSON.stringify(Object.fromEntries(params))}`);
const access_token = params.get("access_token");
const id_token = params.get("id_token");
const expires_in = params.get("expires_in");
const refresh_token = params.get("refresh_token");
debug?.(`Tokens extracted - Access token: ${access_token ? "present" : "missing"}, ID token: ${id_token ? "present" : "missing"}, Refresh token: ${refresh_token ? "present" : "missing"}`);
if (!access_token) {
debug?.("Required access token missing in implicit flow response");
await clear();
throw new Error("Access token missing in implicit flow");
}
// Derive expiry from the access-token's exp claim (server time) to avoid
// issues with client-clock skew. Fall back to the expires_in field only if
// parsing fails.
let expireAt;
try {
const { exp } = parseJwtPayload(access_token);
expireAt = new Date(exp * 1000);
}
catch {
expireAt = new Date(Date.now() + Number(expires_in ?? "3600") * 1000);
}
debug?.(`Token expiry set to: ${expireAt.toISOString()}`);
// Extract username from access token
let username;
try {
const payload = parseJwtPayload(access_token);
username = payload.username;
}
catch (error) {
debug?.("Failed to extract username from access token:", error);
throw new Error("Invalid access token received from OAuth");
}
// Prepare tokens for processTokens
const tokensForProcessing = {
accessToken: access_token,
idToken: id_token || "", // Empty string if missing - will be handled by processTokens
refreshToken: refresh_token || "",
expireAt,
username,
authMethod: "REDIRECT",
// OAuth doesn't provide device metadata
newDeviceMetadata: undefined,
userConfirmationNecessary: false,
};
debug?.("Processing OAuth tokens through standard flow (implicit)");
// Process tokens through the standard flow
tokens = (await processTokens(tokensForProcessing));
debug?.("OAuth tokens successfully processed (implicit flow)");
}
// cleanup URL
debug?.(`Cleaning up URL, pushing state to: ${fullRedirectUrl}`);
cfg.history.pushState(null, "", fullRedirectUrl);
await clear();
debug?.("OAuth flow completed successfully");
return tokens;
}
async function exchangeCodeForTokens(code) {
const cfg = configure();
const { debug } = cfg;
debug?.("Beginning code-to-token exchange");
const { redirectSignIn } = cfg.hostedUi;
// Get the OAuth token endpoint
const tokenEndpoint = getTokenEndpoint();
debug?.(`Using OAuth token endpoint: ${tokenEndpoint}`);
// Handle relative redirectSignIn for the token exchange
let fullRedirectUrl = redirectSignIn;
if (redirectSignIn && !redirectSignIn.match(/^https?:\/\//)) {
debug?.(`Converting relative redirectSignIn "${redirectSignIn}" to absolute URL for token exchange`);
// Create a URL object using the current location as base
const currentUrl = new URL(cfg.location.href);
// Create a new URL with the current as base and the redirect path as the path
const absoluteUrl = new URL(redirectSignIn, currentUrl.origin);
fullRedirectUrl = absoluteUrl.href;
debug?.(`Using absolute URL for token exchange: ${fullRedirectUrl}`);
}
// Wrap PKCE retrieval in a lock to ensure consistency
const oauthLockKey = `Passwordless.${cfg.clientId}.oauthLock`;
let verifier = "";
try {
await withStorageLock(oauthLockKey, async () => {
verifier = (await cfg.storage.getItem(PKCE_KEY)) ?? "";
debug?.(`PKCE verifier retrieved: ${verifier ? "present" : "missing"}`);
});
}
catch (error) {
if (error instanceof LockTimeoutError) {
debug?.("⏱️ [OAuth] Lock timeout during PKCE retrieval");
throw new Error("OAuth operation in progress. Please try again.");
}
throw error;
}
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: cfg.clientId,
code,
redirect_uri: fullRedirectUrl,
code_verifier: verifier,
}).toString();
debug?.(`Token endpoint: ${tokenEndpoint}`);
debug?.("Sending token exchange request");
let res;
try {
res = await cfg.fetch(tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
}
catch (err) {
debug?.("Token exchange network error:", err);
await clear();
throw new Error(err instanceof Error ? err.message : "Network error during token exchange");
}
debug?.(`Token exchange response success: ${res.ok ? "yes" : "no"}`);
if (!res.ok) {
debug?.("Token exchange failed");
await clear();
const errJson = await res.json();
debug?.(`Error response: ${JSON.stringify(errJson)}`);
let message = "Token exchange failed";
if (errJson &&
typeof errJson === "object" &&
"error_description" in errJson &&
typeof errJson.error_description === "string") {
message = errJson.error_description;
}
else if (errJson &&
typeof errJson === "object" &&
"error" in errJson &&
typeof errJson.error === "string") {
message = errJson.error;
}
debug?.(`Throwing error: ${message}`);
throw new Error(message);
}
debug?.("Token exchange successful, parsing response");
const json = (await res.json());
debug?.(`Tokens received - Access token: ${json.access_token ? "present" : "missing"}, ID token: ${json.id_token ? "present" : "missing"}, Refresh token: ${json.refresh_token ? "present" : "missing"}, Expires in: ${json.expires_in}s`);
// Derive expiry from the access-token's exp claim (server time) to avoid
// issues with client-clock skew. Fall back to the expires_in field only if
// parsing fails.
let expireAt;
try {
const { exp } = parseJwtPayload(json.access_token);
expireAt = new Date(exp * 1000);
}
catch {
expireAt = new Date(Date.now() + json.expires_in * 1000);
}
debug?.(`Token expiry set to: ${expireAt.toISOString()}`);
// Extract username from access token
let username;
try {
const payload = parseJwtPayload(json.access_token);
username = payload.username;
}
catch (error) {
debug?.("Failed to extract username from access token:", error);
throw new Error("Invalid access token received from OAuth");
}
// Validate we have required tokens
if (!json.id_token) {
debug?.("Warning: No ID token in OAuth response");
// For OAuth flows without ID token, we'll create a minimal one later
}
// Prepare tokens for processTokens
const tokensForProcessing = {
accessToken: json.access_token,
idToken: json.id_token || "", // Empty string if missing - will be handled by processTokens
refreshToken: json.refresh_token || "",
expireAt,
username,
authMethod: "REDIRECT",
// OAuth doesn't provide device metadata
newDeviceMetadata: undefined,
userConfirmationNecessary: false,
};
debug?.("Processing OAuth tokens through standard flow");
// Process tokens through the standard flow
// This will handle storage, refresh scheduling, and any callbacks
const processedTokens = await processTokens(tokensForProcessing);
debug?.("OAuth tokens successfully processed");
return processedTokens;
}
async function clearInternal() {
const cfg = configure();
const { debug } = cfg;
debug?.("Clearing OAuth storage keys");
await cfg.storage.removeItem(STATE_KEY);
await cfg.storage.removeItem(PKCE_KEY);
await cfg.storage.removeItem(OAUTH_IN_PROGRESS_KEY);
debug?.("OAuth storage keys cleared");
}
async function clear() {
const cfg = configure();
const { debug } = cfg;
const oauthLockKey = `Passwordless.${cfg.clientId}.oauthLock`;
debug?.("🔒 [OAuth] Acquiring lock for clearing OAuth state");
try {
await withStorageLock(oauthLockKey, async () => clearInternal());
}
catch (error) {
if (error instanceof LockTimeoutError) {
debug?.("⏱️ [OAuth] Lock timeout during clear - another OAuth operation is in progress");
throw new Error("Cannot clear OAuth state: another OAuth operation is in progress. Please try again.");
}
else {
throw error;
}
}
}
// Utility re-exports for other modules
export { generateRandomString } from "./oauthUtil.js";