awslabs / cognito-at-edge

Serverless authentication solution to protect your website or Amplify application
Apache License 2.0
168 stars 54 forks source link

Helper function to simply check if the user is logged in #21

Open keokilee opened 2 years ago

keokilee commented 2 years ago

What would you like to be added:

Trying to grok the code and think that it's pretty awesome that you can hand off the request to this library and it takes care of authenticating the user. However, we set up Cloudfront to be on a wildcard domain, and we have logic in our Viewer Request function to inspect the Host header and store that in another header for our Origin Request function. What I would like is a simple function to determine if the user is authenticated or not. With that, I would know when to trigger our custom logic.

Although, supporting multiple domains would be interesting. Maybe that's a separate request though.

Why is this needed:

It'll just give us some options when integrating this package with an existing viewer request function. The only other way would be to inspect the output of the handle function.

jeandek commented 2 years ago

Hi @keokilee,

Thanks for raising this issue and submitting a PR. I understand that you want to apply some custom logic to the request after authentication with Cognito is taken care of.

In #20, the issue creator says:

For my use case I'd love to add a callback to the handle function which allows me to change the return logic on the basis of a user's decoded token. For example, to return a 403 error if a user doesn't have a Cognito group granting them access.

I'm wondering if this approach would work for your use case?

Regards,

keokilee commented 2 years ago

Yeah, I think that'd work. Just needed something simple to check if the user is authenticated, but being able to hook into the authentication response and add custom handling would be great as well.

67726e commented 1 year ago

Not sure if this would make sense for a pull-request as it would constitute as breaking change to the API for anyone already doing enrichment of the authenticator.handle return.

I've internally forked the code at work because we have a handful of changes and can't wait around for the Pull Request / Release cycle on the library. What I've done is the following:

  1. Add Enriched Return Type(s) with tokens: Tokens and isAuthenticated: boolean
  2. Add Access Token to the "Get Tokens from Cookies" Routine
  3. Add Access Token Verification, Enriched Return Types in the handle method.

Add Type(s) for Authenticator.handle

export type AuthenticatedResponse = { result: CloudFrontRequest, tokens: Tokens, isAuthenticated: true, };
export type CloudFrontRedirectionResponse = { result: CloudFrontRequestResult, isAuthenticated: false, };
export type CognitoRedirectionResponse = { result: CloudFrontRequestResult, isAuthenticated: false, };
export type HandleResponse =
  AuthenticatedResponse | CloudFrontRedirectionResponse | CognitoRedirectionResponse;

Update the Authenticator._getTokensFromCookie, Add Access Token

_getTokensFromCookie(cookieHeaders: Array<{ key?: string | undefined, value: string }> | undefined): Tokens {
  if (!cookieHeaders) {
    this._logger.debug("Cookies weren't present in the request");
    throw new Error("Cookies weren't present in the request");
  }

  this._logger.debug({ msg: 'Extracting authentication token from request cookie', cookieHeaders });

  const cookies = cookieHeaders.flatMap(h => Cookies.parse(h.value));

  const tokenCookieNamePrefix = `${this._cookieBase}.`;
  const accessTokenCookieNamePostfix = '.accessToken';
  const idTokenCookieNamePostfix = '.idToken';
  const refreshTokenCookieNamePostfix = '.refreshToken';

  const tokens: Tokens = {};
  for (const {name, value} of cookies){
    if (name.startsWith(tokenCookieNamePrefix) && name.endsWith(accessTokenCookieNamePostfix)) {
      tokens.accessToken = value;
    }
    if (name.startsWith(tokenCookieNamePrefix) && name.endsWith(idTokenCookieNamePostfix)) {
      tokens.idToken = value;
    }
    if (name.startsWith(tokenCookieNamePrefix) && name.endsWith(refreshTokenCookieNamePostfix)) {
      tokens.refreshToken = value;
    }
  }

  if (!tokens.accessToken && !tokens.idToken && !tokens.refreshToken) {
    this._logger.debug('Neither accessToken, nor idToken, nor refreshToken was present in request cookies');

    throw new Error('Neither accessToken, nor idToken, nor refreshToken was present in request cookies');
  }

  this._logger.debug({ msg: 'Found tokens in cookie', tokens });
  return tokens;
  }

Update the Authenticator.handle, Add Access Token Verification, Update Return Type(s)

async handle(event: CloudFrontRequestEvent): Promise<HandleResponse> {
  this._logger.debug({ msg: 'Handling Lambda@Edge event', event });

  const { request } = event.Records[0].cf;
  const requestParams = parse(request.querystring);
  const cfDomain = request.headers.host[0].value;
  const redirectURI = `https://${cfDomain}`;

  // 1. Fetch Token(s) from Coookie(s)
  // 2. Verify Access Token, ID Token from Cookie(s)
  // 3. Optionally, On Failure of (2), Attempt Token Refresh
  // 4. Optionally, On Failure of (3), Check for `?code=STRING` on Request Parameters
  //  - Handle as Cognito Hosted UI Authentication Redirect Response
  //  - Fetch Token(s) from Cognito Hosted UI Authentication Redirect Response
  //  - Redirect to CloudFront (Self) w/ Token(s) in Cookie Header(s), Triggering Step #1, #2
  // 5. Finally, On Failure of (4), Redirect to Cognito Hosted UI for Authentication

  try {
    // 1. Find Token(s) from Coookie(s)
    const headerTokens = this._getTokensFromCookie(request.headers.cookie);

    this._logger.debug({ msg: 'Verifying token...', tokens: headerTokens });

    try {
      // 2. Verify Access Token, ID Token from Cookie(s)
      const accessToken = await this._accessTokenVerifier.verify(headerTokens.accessToken);
      const idToken = await this._idTokenVerifier.verify(headerTokens.idToken);

      this._logger.info({ msg: 'Forwarding request', path: request.uri, accessToken, idToken, });

      return { result: request, tokens: headerTokens, isAuthenticated: true, };
    } catch (err) {
      // 3. Optionally, On Failure of (2), Attempt Token Refresh
      if (headerTokens.refreshToken) {
        this._logger.debug({ msg: 'Verifying accessToken and idToken failed, verifying refresh token instead...', tokens: headerTokens, err });

        const refreshedTokens = await this._fetchTokensFromRefreshToken(redirectURI, headerTokens.refreshToken);
        const response = await this._getRedirectToCloudFrontResponse(refreshedTokens, cfDomain, request.uri);

        return { result: response, isAuthenticated: false, };
      } else {
        throw err;
      }
    }
  } catch (err) {
    this._logger.debug("User isn't authenticated: %s", err);

    // TODO: Add `path` Configuration, i.e. `/idpresponse` a la AWS ALB?
    if (requestParams.code) {
      // 4. Optionally, On Failure of (3), Check for `?code=STRING` on Request Parameters
      const codeTokens = await this._fetchTokensFromCode(redirectURI, requestParams.code);
      const response = await this._getRedirectToCloudFrontResponse(codeTokens, cfDomain, requestParams.state as string);

      return { result: response, isAuthenticated: false, };
    } else {
      // 5. Finally, On Failure of (4), Redirect to Cognito Hosted UI for Authentication
      const response = this._getRedirectToCognitoUserPoolResponse(request, redirectURI);

      return { result: response, isAuthenticated: false, };
    }
  }
}

@jeandek would it make sense to add these changes into a Pull Request, or would this breaking change be rejected? I can happily put in an PR for you to make this all possible, update the README, etc.

67726e commented 1 year ago

The resulting changes to my root Lambda function look like the following:

// cognito-at-edge
function authenticationHandler({ event }): Promise<HandleResponse> {
    return authenticator.handle(event);
}

// Lambda@Edge Handler
export const main = async (event, context, callback) => {
    const response = await authenticationHandler({ event });

    if (response.isAuthenticated) {
        // Add Apache-style `DocumentRoot` Functionality
        documentRootHandler({ request: response.result });

        // Add Access Token to Header, i.e. `X-Cf-Accesstoken` a la AWS ALB `X-Amzn-Accesstoken`
        authenticationHeaderHandler({ request: response.result, tokens: response.tokens });
    }

    return response.result;
};