aws-amplify / docs

AWS Amplify Framework Documentation
https://docs.amplify.aws
Apache License 2.0
487 stars 1.06k forks source link

identityPool guest data access fails for authenticated users when amplify ssr is true #8095

Open leonanderson88 opened 3 months ago

leonanderson88 commented 3 months ago

Environment information

System:
  OS: macOS 14.5
  CPU: (16) arm64 Apple M3 Max
  Memory: 2.39 GB / 64.00 GB
  Shell: /bin/zsh
Binaries:
  Node: 22.5.1 - ~/.nvm/versions/node/v22.5.1/bin/node
  Yarn: undefined - undefined
  npm: 10.8.2 - ~/.nvm/versions/node/v22.5.1/bin/npm
  pnpm: undefined - undefined
NPM Packages:
  @aws-amplify/auth-construct: 1.2.0
  @aws-amplify/backend: 1.0.4
  @aws-amplify/backend-auth: 1.1.0
  @aws-amplify/backend-cli: 1.2.1
  @aws-amplify/backend-data: 1.1.0
  @aws-amplify/backend-deployer: 1.0.2
  @aws-amplify/backend-function: 1.3.0
  @aws-amplify/backend-output-schemas: 1.1.0
  @aws-amplify/backend-output-storage: 1.0.2
  @aws-amplify/backend-secret: 1.0.0
  @aws-amplify/backend-storage: 1.0.4
  @aws-amplify/cli-core: 1.1.1
  @aws-amplify/client-config: 1.1.1
  @aws-amplify/deployed-backend-client: 1.1.0
  @aws-amplify/form-generator: 1.0.0
  @aws-amplify/model-generator: 1.0.2
  @aws-amplify/platform-core: 1.0.3
  @aws-amplify/plugin-types: 1.1.0
  @aws-amplify/sandbox: 1.1.1
  @aws-amplify/schema-generator: 1.2.0
  aws-amplify: 6.4.3
  aws-cdk: 2.150.0
  aws-cdk-lib: 2.150.0
  typescript: 5.5.4
AWS environment variables:
  AWS_STS_REGIONAL_ENDPOINTS = regional
  AWS_NODEJS_CONNECTION_REUSE_ENABLED = 1
  AWS_SDK_LOAD_CONFIG = 1
No CDK environment variables

Data packages

onlihoney.com.au@0.1.0 /Users/leonanderson/Sites/onlihoney.com.au
├─┬ @aws-amplify/backend-cli@1.2.1
│ └─┬ @aws-amplify/schema-generator@1.2.0
│   └── @aws-amplify/graphql-schema-generator@0.9.3
└─┬ @aws-amplify/backend@1.0.4
  └─┬ @aws-amplify/backend-data@1.1.0
    └── @aws-amplify/data-construct@1.9.3

Description

Current calls and issues

Data set up with access pattern.

allow.guest().to("read"),
allow.authenticated("identityPool").to("read"),
allow.groups(["ADMINS"])

And call to data from front end home page

Amplify.configure(outputs, { ssr: true });

const client = generateClient({ authMode: "identityPool" });

Unauthenticated (guest) user can access data. Authenticated user (Signed in) cannot access data. Receive error:

"UnauthorizedException: Unknown error\n    at parseJsonError (webpack-internal:///(app-pages-browser)/./node_modules/@aws-amplify/core/dist/esm/clients/serde/json.mjs:34:19)\n    at async parseRestApiServiceError (webpack-internal:///(app-pages-browser)/./node_modules/@aws-amplify/api-rest/dist/esm/utils/serviceError.mjs:26:28)\n    at async job (webpack-internal:///(app-pages-browser)/./node_modules/@aws-amplify/api-rest/dist/esm/utils/createCancellableOperation.mjs:35:23)\n    at async GraphQLAPIClass._graphql (webpack-internal:///(app-pages-browser)/./node_modules/@aws-amplify/api-graphql/dist/esm/internals/InternalGraphQLAPI.mjs:265:44)\n    at async _list (webpack-internal:///(app-pages-browser)/./node_modules/@aws-amplify/data-schema/dist/esm/runtime/internals/operations/list.mjs:32:16)\n    at async fetchProducts (webpack-internal:///(app-pages-browser)/./src/components/shoppingCart/ProductCards.js:27:48)"

With recovery suggestion:

"If you're calling an Amplify-generated API, make sure to set the "authMode" in generateClient({ authMode: '...' }) to the backend authorization rule's auth provider ('apiKey', 'userPool', 'iam', 'oidc', 'lambda')"

Note: No option listed for identityPool

This issue does not present if SSR True flag is not used:

Amplify.configure(outputs);

However SSR is needed for my use case as I have API routes with SSR function calls to mutations etc.

Expected behaviour.

ADMINS can read,write,delete data when calls to Data via userPool are made: Works Authenticated users can read data when calls to Data via identityPool are made: Error Guest users can read data when calls to Data via identityPool are made: Works

leonanderson88 commented 3 months ago

Access to data by both guest and authenticated users works via:

allow.publicApiKey().to(["read"])

However this is not recommended for production environments based on documentation: https://docs.amplify.aws/nextjs/build-a-backend/data/customize-authz/

AnilMaktala commented 3 months ago

Hey,👋 thanks for raising this! I'm going to transfer this over to our JS repository for better assistance 🙂

chrisbonifacio commented 3 months ago

Hi @leonanderson88 👋 thanks for raising this issue. Before I reproduce this issue, can you please check the request details of the requests being made with SSR enabled vs disabled? I'm curious if the Authorization headers are any different between the two requests.

For a bit more context, the difference between SSR true/false is that, when disabled (default), credentials are stored in localStorage. When enabled, credentials are stored in cookies so they can be consumed on the server side and used to authorize requests.

Also, can you confirm whether those requests are being made on the client or server side?

Lastly, if you haven't already come across this page in the docs please refer to it for server side data. Connecting to data in a server context requires a particular type of client depending on whether you are using middleware or not:

https://docs.amplify.aws/react/build-a-backend/data/connect-from-server-runtime/nextjs-server-runtime/

leonanderson88 commented 3 months ago

Hi @chrisbonifacio Thank you for clarifying how SSR credentials are stored and transported. I was aware of the cookies but wasn't aware that local storage was used for client only.

There are only slight differences in the authorisation headers, namely the signature is different. But the structure is the same. X-Amz-Security-Token is set Authorisation header is set the same structure with only signature differences. I assume this different on each request.

The request that is failing is on the client side. In my use case, users land on a public front page of the website and the client makes a call to data to show products.

The SSR calls I am making are from my /api routes for mutations and queries that may or may not come from authenticated users. They make calls using a cookies cookieBasedClient.

chrisbonifacio commented 3 months ago

Hi @leonanderson88 , I'm trying to reproduce this issue but so far I am not able to.

I'm configuring Amplify with ssr:true in the root layout (client component). I have two pages, one that renders only a client component and one that renders a server component. Both pages are able to read from the model which should be protected by identityPool auth, both guest and authenticated.

Here's my repro app: https://github.com/chrisbonifacio/amplify-next-isr

Is there anything you notice I might be doing differently from your app?

Or, if you can share a sample app that reproduces the issue please do.

In any case, it seems if Amplify is configured with ssr: true on the client side, server side requests should not be affected. It's not currently clear to me why the behavior would change, especially if both requests are being signed with IAM roles as the headers you mentioned would suggest.

My best guess, judging from the error stack trace and it mentioning parseJsonError from serde (serialize/deserialize), that there was an error response from AppSync that was not able to be de-serialized for some reason. Would you be able to share the exact response payload if you can see it in the network activity?

leonanderson88 commented 3 months ago

@robokozo To access data using the allow guest pattern:

allow.guest()

You need to either change the default auth mode to 'identityPool'

export const data = defineData({
  schema,
  authorizationModes: {
    //defaultAuthorizationMode: 'userPool',
    defaultAuthorizationMode: 'identityPool',
  },
})

Or specify this as the auth method on the client when you create it:

const client = generateClient({authMode:'identityPool'});

Which would be in your 01.amplify-apis.client.ts file if you are following the guides on this page for a Nuxt.js setup. https://docs.amplify.aws/nextjs/build-a-backend/data/connect-from-server-runtime/nuxtjs-server-runtime/

The authorisations strategy is set out here: https://docs.amplify.aws/nextjs/build-a-backend/data/customize-authz/

IdentityPool covers Guest and Authenticated checks UserPool covers Owner and Group checks

leonanderson88 commented 3 months ago

@chrisbonifacio

I have downloaded and run your repo to check and uncovered more to clarify this issue.

I will clarify as well what is not working based on your Todos app repo. Using identityPool the following users can list todos:

However, I added some extra resources to more closely model my app. I added the storage definition with a few items that reference groups for access:

import { defineStorage } from "@aws-amplify/backend";

export const storage = defineStorage({
  name: "amplify-next-isr",
  access: (allow) => ({
    "profile-pictures/{entity_id}/*": [
      allow.guest.to(["read"]),
      allow.authenticated.to(["read"]),
      allow.entity("identity").to(["read", "write", "delete"]),
    ],
    "product-pictures/*": [
      allow.guest.to(["read"]),
      allow.authenticated.to(["read"]),
      allow.groups(["ADMINS", "EDITORS"]).to(["read", "write", "delete"]),
    ],
    "media/*": [
      allow.guest.to(["read"]),
      allow.authenticated.to(["read"]),
      allow.groups(["ADMINS", "EDITORS"]).to(["read", "write", "delete"]),
    ]
  }),
});

Amplify Sand Box would not deploy because

No auth IAM role found for "ADMINS".
Resolution: If you are trying to configure UserPool group access, ensure that the group name is specified correctly.

So I added the following to the auth resource.ts

export const auth = defineAuth({
  loginWith: {
    email: true,
  },
  groups: ["ADMINS", "EDITORS"],
});

Now the deploy worked.

This is where I think the problem lies. When I add the user to one of the groups created in auth resource.ts the user cannot view the Todos with the same error unauthenticated I get in my app:

I have tested this behavoir in my app by removing my user from the ADMINS group in Cognito dashboard and then adding them again. I get the same access pattern as above:

Interestingly, I tested creating the groups from Cognito dashboard and access worked:

But I cannot use this method in my app as storage resource won't allow deploy unless groups are defined in the auth resource.ts

In your repo can you try adding the storage resource and updating the auth resource.ts as above. Then add a user to the ADMINS group to recreate the issue.

robokozo commented 3 months ago

Thanks anyway @leonanderson88. I deleted my posted because I tried the allow.authenticated("identityPool").to("read"), and it started working as expected

chrisbonifacio commented 3 months ago

Hi @leonanderson88, according to your latest comment, it seems that users that belong to a group are not being granted access to a model.

Guest - Works Authenticated - Works Authenticated (in ADMINS Group) - ERROR authentication

There are some things that might help diagnose the root cause of the issue. If you could share

  1. your full schema
  2. the way you are generating the server-side cookieBasedClient
  3. any relevant network logs so that we can see how the requests are being authorized or signed
  4. the code that is performing the request so that we can see what authMode is being passed at run time, if any other than the default

My best guess is that because the default authMode is identityPool, and the user is logged in AND belongs to a group, maybe there is some mismatch or mistake in the way the request is being generated.

One thing you can try is to set the authMode to userPool if the authenticated user's access token has a groups claim so that they can have the full permissions of that group rather than trying to authorize the request with the identityPool authMode, which has less permissions.

leonanderson88 commented 3 months ago

Hi @chrisbonifacio

No worries. Here is the code:

  1. https://github.com/leonanderson88/onlihoney.com.au/blob/dev/amplify/data/resource.ts - Schema
  2. https://github.com/leonanderson88/onlihoney.com.au/blob/dev/src/lib/amplify-utils.js - Server Cookie Client
  3. https://github.com/leonanderson88/onlihoney.com.au/blob/dev/localhost.har - HAR network file
  4. https://github.com/leonanderson88/onlihoney.com.au/blob/dev/src/components/shoppingCart/ProductCards.js - Call to data client on client side

You should have complete access to the Repo now.

The behaviour that is odd is that using default identityPool works for users attached to groups as long as the groups were not defined inside the auth/resource.ts file.

I have tried calling the data client with the default set to identityPool and explicitly setting the data client to identity pool in the ProductCards.js component. Neither resolves this issue.

I suppose I could set the call to:

const authSession = await fetchAuthSession();
const userGroups = authSession.tokens.accessToken.payload["cognito:groups"] || [];
const isAdmin = includesAny(userGroups, ["ADMINS", "EDITORS"]); // includesAny = (arr, values) => values.some(v => arr.includes(v));
const client = (isAdmin)?generateClient({authMode:'userPool'}):generateClient({authMode:'identityPool'});

But this seems like a clunky work around to something that is almost working perfectly except for the groups issue. I'd be more inclined to use publicApiKey instead as the above solution has to wait for a response before checking and will add latency to the client call.

chrisbonifacio commented 3 months ago

Thank you for sharing the info, I will take a look and report back with any findings.

const client = (isAdmin)?generateClient({authMode:'userPool'}):generateClient({authMode:'identityPool'});

You don't have to generate another client, you can have a single client with a default authMode of identityPool and import it where you need it. You just have to change the authMode on a request like this:

const { errors, data: newSalary } = await client.models.Salary.create(
  {
    wage: 50.25,
    currency: 'USD'
  },
  {
    authMode: isAdmin ? 'userPool' : 'identityPool',
  }
);
chrisbonifacio commented 3 months ago

Hi @leonanderson88 thanks again for sharing the repo. Unfortunately, I ran into issues trying to deploy the same backend. I ran into errors like the following when simply running npx ampx sandbox

CleanShot 2024-08-13 at 12 21 57@2x

It's still not clear to me why you're experiencing this issue and I'm unable to reproduce it on the latest version of aws-amplify.

Have you tried upgrading as well?

If that doesn't help, and if you can, please create a branch that simplifies the backend just enough to reproduce the issue consistently as that would help us identify the problem.

leonanderson88 commented 3 months ago

Hi @chrisbonifacio

How strange. I don't get this issue. I will upgrade my lambda functions to NodeJS 20. I'm not sure why the default is 18 for this. It really should be v20 and only v18 if requested.

I have forked your example from before and made the necessary adjustments to reproduce the issue. https://github.com/leonanderson88/amplify-next-isr

Running this you should see that an authenticated user can see the todo item. But if you then go to Cognito console and add that user to the ADMINS group; log them out and log back in; the authenticated user will no longer see the TODO.

espenbye commented 2 weeks ago

I'm encountering the same issue. When I'm logged in as an admin (UserPool) and visit pages that should use IdentityPool authentication, the Next.js server actions fail to fetch data. The presence of UserPool cookies in the client seems to cause a conflict with the IdentityPool authentication mode.

Steps to reproduce:

  1. Log in as admin (UserPool authentication)
  2. Visit any page that should use IdentityPool auth (public data)
  3. Server actions fail with auth mode type error
leonanderson88 commented 2 weeks ago

This is how I managed to create a work around. Still no word on how a permanent fix to this. But I have determined it only appears when you use the amplify/storage library to create user groups.

@espenbye I had to use a fairly hacky method of checking a user before creating a public client and calling for data. It can use the existing user tokens without needing a server call. However there is a need to 1 extra server call to fetchUserGroups. Fortunately, with a context provider this only needs to be done once when the user is authenticated.

My method was to use a flag before defining the authMode any time i needed a client.

It's a lot of extra boilerplate...

  const { isEditor } = useNavigation();

  const client = generateClient({
    authMode: isEditor ? "userPool" : "identityPool",
  });

I created a useContext component for my own purposes and wrapped the application in that. I called NavigationProvider As long as my provider was lower in the tree than AmplifyProvider i could make calls to amplify/auth to test the users session.

app/layout.js


import { Authenticator } from "@aws-amplify/ui-react";
import { NavigationProvider } from "@/src/lib/providers/NavigationProvider";

export default function RootLayout({ children }) {
  return(
     <html lang="en-AU">
      <body>
        <Authenticator.Provider>
          <NavigationProvider>{children}</NavigationProvider>
        </Authenticator.Provider>
      </body>
    </html>
  );
}

The navigation provider makes these calls to determine if a user is logged in and is an Admin/Editor or not @/src/lib/providers/NavigationProvider.js

import { useAuthenticator } from "@aws-amplify/ui-react";
import { fetchUserGroups } from "@/src/lib/providers/AmplifyProvider";
import { createContext, useContext, useEffect, useState } from "react";

export const NavigationContext = createContext();

export const NavigationProvider = ({ children }) => {
  const { user } = useAuthenticator((context) => [context.user]);

  const [isAdmin, setIsAdmin] = useState(false);
  const [isEditor, setIsEditor] = useState(false);

  useEffect(() => {
    if (user) {
      fetchAdminRole();
    } else {
      setIsAdmin(false);
      setIsEditor(false);
    }
  }, [user]);

  const fetchAdminRole = async () => {
    const userGroups = await fetchUserGroups();
    setIsAdmin(includesAny(userGroups, ["ADMINS"]));
    setIsEditor(includesAny(userGroups, ["ADMINS", "EDITORS"]));
  };

  const includesAny = (arr, values) => values.some(v => arr.includes(v));

  return (
    <NavigationContext.Provider
      value={{
        user,
        isEditor,
        isAdmin,
      }}
    >
      {children}
    </NavigationContext.Provider>
  );
}

export const useNavigation = () => useContext(NavigationContext);
chrisbonifacio commented 2 weeks ago

Hey @leonanderson88 ! apologies for the delay on this. I actually was able to reproduce the behavior described here.

I deployed the app you shared and thought that the issue was on the isr page and kinda lost some time there.

I also forgot to sign out in between sessions when I added or removed a user from the user pool group which made it seem like there was no difference in behavior. Once I realized this, I signed out and refreshed my token to make sure it had the access token had a group claim and then I was able to consistently reproduce the bug even on the root landing page.

here's my schema:

  Todo: a
    .model({
      content: a.string(),
      type: a.string(),
    })
    .secondaryIndexes((index) => [index("type").queryField("listByType")])
    .authorization((allow) => [
      allow.guest().to(["read"]),
      allow.authenticated("identityPool").to(["create", "read"]),
      allow.groups(["ADMINS"]),
    ]),

the only change i made was I allowed authenticated identityPool users to create a Todo so I could test creation. I did this because I was unable, or unauthorized, to create a Todo when assigned to a group.

querying for Todo as an authenticated user that isn't assigned to a group: CleanShot 2024-11-01 at 12 48 36@2x

querying for Todo as an authenticated user that IS assigned to a group (ADMINS): CleanShot 2024-11-01 at 12 53 37@2x

The workaround you shared of checking for a group and switching to identityPool makes sense in the meantime until we address this issue.

I've labeled this a bug for the team to investigate further.

Note: I was able to reproduce this regardless of Amplify being configured with ssr: true or not.