aws-amplify / amplify-js

A declarative JavaScript library for application development using cloud services.
https://docs.amplify.aws/lib/q/platform/js
Apache License 2.0
9.43k stars 2.13k forks source link

Received an UnauthorizedException when calling GraphQL with Auth0 as OIDC. #13252

Closed TitusEfferian closed 5 months ago

TitusEfferian commented 6 months ago

Before opening, please confirm:

JavaScript Framework

React

Amplify APIs

GraphQL API

Amplify Version

v6

Amplify Categories

api

Backend

Amplify CLI

Environment information

``` System: OS: macOS 14.4.1 CPU: (10) arm64 Apple M2 Pro Memory: 997.81 MB / 32.00 GB Shell: 5.9 - /bin/zsh Binaries: Node: 18.19.1 - ~/.nvm/versions/node/v18.19.1/bin/node Yarn: 1.22.19 - ~/.nvm/versions/node/v18.19.1/bin/yarn npm: 10.2.4 - ~/.nvm/versions/node/v18.19.1/bin/npm Browsers: Chrome: 123.0.6312.124 Chrome Canary: 125.0.6421.0 Edge: 123.0.2420.97 Safari: 17.4.1 npmPackages: @auth0/auth0-react: ^2.2.4 => 2.2.4 @aws-sdk/client-cognito-identity: ^3.540.0 => 3.554.0 @types/react: ^18.2.66 => 18.2.77 @types/react-dom: ^18.2.22 => 18.2.25 @typescript-eslint/eslint-plugin: ^7.2.0 => 7.6.0 @typescript-eslint/parser: ^7.2.0 => 7.6.0 @vitejs/plugin-react: ^4.2.1 => 4.2.1 aws-amplify: ^6.0.28 => 6.0.28 aws-amplify/adapter-core: undefined () aws-amplify/analytics: undefined () aws-amplify/analytics/kinesis: undefined () aws-amplify/analytics/kinesis-firehose: undefined () aws-amplify/analytics/personalize: undefined () aws-amplify/analytics/pinpoint: undefined () aws-amplify/api: undefined () aws-amplify/api/server: undefined () aws-amplify/auth: undefined () aws-amplify/auth/cognito: undefined () aws-amplify/auth/cognito/server: undefined () aws-amplify/auth/enable-oauth-listener: undefined () aws-amplify/auth/server: undefined () aws-amplify/data: undefined () aws-amplify/data/server: undefined () aws-amplify/datastore: undefined () aws-amplify/in-app-messaging: undefined () aws-amplify/in-app-messaging/pinpoint: undefined () aws-amplify/push-notifications: undefined () aws-amplify/push-notifications/pinpoint: undefined () aws-amplify/storage: undefined () aws-amplify/storage/s3: undefined () aws-amplify/storage/s3/server: undefined () aws-amplify/storage/server: undefined () aws-amplify/utils: undefined () eslint: ^8.57.0 => 8.57.0 eslint-plugin-react-hooks: ^4.6.0 => 4.6.0 eslint-plugin-react-refresh: ^0.4.6 => 0.4.6 react: ^18.2.0 => 18.2.0 react-dom: ^18.2.0 => 18.2.0 typescript: ^5.2.2 => 5.4.5 vite: ^5.2.0 => 5.2.8 npmGlobalPackages: @aws-amplify/cli: 12.10.1 corepack: 0.22.0 npm: 10.2.4 vercel: 33.6.1 yarn: 1.22.22 ```

Describe the bug

I am trying to perform some CRUD operations with GraphQL, using Auth0 as the OIDC provider. I have successfully logged in with Auth0, obtained the idToken, passed it to Amplify Auth, and received all the results within fetchAuthSession(). Now, I am planning to hit a GraphQL endpoint, but I encountered an "UnauthorizedException" error.

I have explored all the available open and closed issues in this repository using the filter is:issue is:open graphql auth0, and I didn’t find any duplicates or relevant issues related to my case. I have also searched in the aws-amplify Discord and still haven't found any information, so I decided to open a new issue here.

Expected behavior

GraphQL returns a 200 status code, with expected data

Reproduction steps

  1. Create an Amplify project.
  2. Create an Auth0 project.
  3. Follow these docs.
  4. Create a GraphQL schema using amplify add api.
  5. Call the GraphQL API.

Code Snippet

main.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { Auth0Provider } from "@auth0/auth0-react";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <Auth0Provider
    domain="my_domain.auth0.com"
    clientId="my_client_id"
    authorizationParams={{
      redirect_uri: "http://localhost:5173",
    }}
  >
    <App />
  </Auth0Provider>,
);

App.tsx

import { useAuth0 } from "@auth0/auth0-react";
import "./App.css";
import { Amplify } from "aws-amplify";
import {
  fetchAuthSession,
  CredentialsAndIdentityIdProvider,
  CredentialsAndIdentityId,
  GetCredentialsOptions,
  decodeJWT,
  TokenProvider,
} from "aws-amplify/auth";
import awsconfig from "./amplifyconfiguration.json";

// Note: This example requires installing `@aws-sdk/client-cognito-identity` to obtain Cognito credentials
// npm i @aws-sdk/client-cognito-identity
import { CognitoIdentity } from "@aws-sdk/client-cognito-identity";

import { generateClient } from "aws-amplify/api";
import { listAnotherTodos } from "./graphql/queries";

// You can make use of the sdk to get identityId and credentials
const cognitoidentity = new CognitoIdentity({
  region: awsconfig.aws_cognito_region,
});

// Note: The custom provider class must implement CredentialsAndIdentityIdProvider
class CustomCredentialsProvider implements CredentialsAndIdentityIdProvider {
  // Example class member that holds the login information
  federatedLogin?: {
    domain: string;
    token: string;
  };

  // Custom method to load the federated login information
  loadFederatedLogin(login?: typeof this.federatedLogin) {
    // You may also persist this by caching if needed
    this.federatedLogin = login;
  }

  async getCredentialsAndIdentityId(
    getCredentialsOptions: GetCredentialsOptions,
  ): Promise<CredentialsAndIdentityId | undefined> {
    try {
      // You can add in some validation to check if the token is available before proceeding
      // You can also refresh the token if it's expired before proceeding

      const getIdResult = await cognitoidentity.getId({
        // Get the identityPoolId from config
        IdentityPoolId: awsconfig.aws_cognito_identity_pool_id,
        Logins: { [this.federatedLogin.domain]: this.federatedLogin.token },
      });

      const cognitoCredentialsResult =
        await cognitoidentity.getCredentialsForIdentity({
          IdentityId: getIdResult.IdentityId,
          Logins: { [this.federatedLogin.domain]: this.federatedLogin.token },
        });

      const credentials: CredentialsAndIdentityId = {
        credentials: {
          accessKeyId: cognitoCredentialsResult.Credentials?.AccessKeyId,
          secretAccessKey: cognitoCredentialsResult.Credentials?.SecretKey,
          sessionToken: cognitoCredentialsResult.Credentials?.SessionToken,
          expiration: cognitoCredentialsResult.Credentials?.Expiration,
        },
        identityId: getIdResult.IdentityId,
      };
      return credentials;
    } catch (e) {
      console.log("Error getting credentials: ", e);
    }
  }
  // Implement this to clear any cached credentials and identityId. This can be called when signing out of the federation service.
  clearCredentialsAndIdentityId(): void {}
}

// Create an instance of your custom provider
const customCredentialsProvider = new CustomCredentialsProvider();

function App() {
  const { loginWithRedirect, getIdTokenClaims, logout, isLoading } = useAuth0();

  if (isLoading) {
    return <div>Loading...</div>;
  }

  return (
    <>
      <button
        onClick={() => {
          loginWithRedirect();
        }}
      >
        login redirect
      </button>
      <button
        // get the token and pass it to amplify
        onClick={async () => {
          try {
            const token = await getIdTokenClaims();
            const myTokenProvider: TokenProvider = {
              async getTokens() {
                return {
                  accessToken: decodeJWT(token?.__raw),
                  idToken: token?.__raw || "",
                };
              },
            };
            Amplify.configure(
              {
                ...awsconfig,
              },
              {
                Auth: {
                  credentialsProvider: customCredentialsProvider,
                  tokenProvider: myTokenProvider,
                },
              },
            );
            await customCredentialsProvider.loadFederatedLogin({
              domain: "my_domain.auth0.com",
              token: token?.__raw || "",
            });
            const fetchSessionResult = await fetchAuthSession(); // will return the credentials
            console.log("fetchSessionResult: ", fetchSessionResult);
          } catch (err) {
            console.log(err);
          }
        }}
      >
        login
      </button>
      <button
        onClick={async () => {
          logout();
        }}
      >
        logout
      </button>
      <button
        // try to call the API
        onClick={async () => {
          const client = generateClient();
          client
            .graphql({
              query: listAnotherTodos,
              // used OIDC as authMode
              authMode: "oidc",
            })
            .then((x) => {
              console.log(x);
            })
            .catch((err) => {
              console.log(err);
            });
        }}
      >
        get
      </button>
    </>
  );
}

export default App;

auth0 api response

{
  "access_token": "my access token",
  "id_token": "my id token",
  "scope": "openid profile email",
  "expires_in": 86400,
  "token_type": "Bearer"
}

pass auth0 information into amplify, and call fetchAuthSession()

{
  "tokens": {
    "accessToken": {
      "payload": {
        "nickname": "my nick name",
        "name": "my name",
        "picture": "cdn url",
        "updated_at": "2024-04-16T02:39:42.626Z",
        "email": "my email",
        "email_verified": true,
        "iss": "https://my-domain.auth0.com/",
        "aud": "xxx",
        "iat": 1713236890,
        "exp": 1713272890,
        "sub": "oauth2|discord|xxx",
        "sid": "sid",
        "nonce": "nonce"
      }
    },
    "idToken": "exact same token from previous auth0 response"
  },
  "credentials": {
    "accessKeyId": "some access key",
    "secretAccessKey": "some secret",
    "sessionToken": "some token",
    "expiration": "2024-04-16T04:09:28.000Z"
  },
  "identityId": "my-identity-id",
  "userSub": "oauth2|discord|xxx"
}

Call the graphql API response

{
  "errors" : [ {
    "errorType" : "UnauthorizedException",
    "message" : "Unauthorized"
  } ]
}

header curl:

curl 'https://my-project-domain.appsync-api.ap-northeast-1.amazonaws.com/graphql' \
 -H 'accept: _/_' \
 -H 'accept-language: en-US,en;q=0.9' \
 -H 'authorization:  exact same token with auth0 response and fetchAuthSession()' \
 -H 'content-type: application/json; charset=UTF-8' \
 -H 'origin: http://localhost:5173' \
 -H 'referer: http://localhost:5173/' \
 -H 'sec-fetch-dest: empty' \
 -H 'sec-fetch-mode: cors' \
 -H 'sec-fetch-site: cross-site' \
 -H 'user-agent: Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1' \
 -H 'x-amz-user-agent: aws-amplify/6.0.27 api/1 framework/1' \
 --data-raw '{"query":"query ListAnotherTodos($filter: ModelAnotherTodoFilterInput, $limit: Int, $nextToken: String) {\n listAnotherTodos(filter: $filter, limit: $limit, nextToken: $nextToken) {\n items {\n id\n name\n description\n sub\n createdAt\n updatedAt\n owner\n **typename\n }\n nextToken\n **typename\n }\n}\n","variables":{}}'

schema.graphql

type AnotherTodo
  @model
  @auth(
    rules: [
      { allow: owner, provider: oidc, identityClaim: "sub" }
    ]
  ) {
  id: ID!
  name: String!
  description: String!
  sub: String!
}

Log output

``` // Put your logs below this line ```

aws-exports.js

/ eslint-disable / // WARNING: DO NOT EDIT. This file is automatically generated by AWS Amplify. It will be overwritten.

const awsmobile = { "aws_project_region": "ap-northeast-1", "aws_appsync_graphqlEndpoint": "https://my-domain.appsync-api.ap-northeast-1.amazonaws.com/graphql", "aws_appsync_region": "ap-northeast-1", "aws_appsync_authenticationType": "API_KEY", "aws_appsync_apiKey": "my key", "aws_cognito_identity_pool_id": "my id", "aws_cognito_region": "ap-northeast-1", "aws_user_pools_id": "ap-northeast-my-id", "aws_user_pools_web_client_id": "my id", "oauth": { "domain": "my-domain-staging.auth.ap-northeast-1.amazoncognito.com", "scope": [ "phone", "email", "openid", "profile", "aws.cognito.signin.user.admin" ], "redirectSignIn": "http://localhost:5173/", "redirectSignOut": "http://localhost:5173/", "responseType": "code" }, "federationTarget": "COGNITO_USER_POOLS", "aws_cognito_username_attributes": [ "EMAIL" ], "aws_cognito_social_providers": [], "aws_cognito_signup_attributes": [ "EMAIL", "NAME" ], "aws_cognito_mfa_configuration": "OFF", "aws_cognito_mfa_types": [ "SMS" ], "aws_cognito_password_protection_settings": { "passwordPolicyMinLength": 8, "passwordPolicyCharacters": [] }, "aws_cognito_verification_mechanisms": [ "EMAIL" ] };

export default awsmobile;

Manual configuration

No response

Additional configuration

No response

Mobile Device

No response

Mobile Operating System

No response

Mobile Browser

No response

Mobile Browser Version

No response

Additional information and screenshots

No response

TitusEfferian commented 6 months ago

hi @cwomack any update for this? 🙇

chrisbonifacio commented 6 months ago

Hi @TitusEfferian it looks like your schema has a sub field but that field is not used as the ownerField in the auth rule. This suggests that Amplify is adding an owner field to the record behind the scenes, which you should be able to see in the DynamoDB console when viewing table items for the model. Can you please confirm that the value being set for owner matches the sub claim on the id token?

I think it's also worth mentioning that your myTokenProvider function is returning both the id and access token. By default, Amplify will send the access token in the Authorization header. Can you please check the outgoing graphql requests in your Network activity and make sure that the token_use claim is what you expect? (in this case id).

TitusEfferian commented 6 months ago

Hi @TitusEfferian it looks like your schema has a sub field but that field is not used as the ownerField in the auth rule. This suggests that Amplify is adding an owner field to the record behind the scenes, which you should be able to see in the DynamoDB console when viewing table items for the model. Can you please confirm that the value being set for owner matches the sub claim on the id token?

I am experimenting with a completely new project, so currently I don't have any items in DynamoDB. I tried creating new data but encountered the same error. Here, I have already attempted to modify the schema again.

type AnotherTodo
  @model
  @auth(rules: [{ allow: owner, provider: oidc, identityClaim: "sub" }]) {
  id: ID!
  name: String!
  description: String!
}
const client = generateClient();
const input: CreateAnotherTodoInput = {
  description: "hello",
  name: "hello",
};
client
  .graphql({
    query: createAnotherTodo,
    authMode: "oidc",
    variables: {
      input,
    },
  })
  .then((x) => {
    console.log(x);
  })
  .catch((err) => {
    console.log(err);
  });
curl 'https://gps6v37nqjhchcxt22t7awvhaq.appsync-api.ap-northeast-1.amazonaws.com/graphql' \
 -H 'accept: _/_' \
 -H 'accept-language: en-US,en;q=0.9' \
 -H 'authorization: token' \
 -H 'content-type: application/json; charset=UTF-8' \
 -H 'origin: http://localhost:5173' \
 -H 'priority: u=1, i' \
 -H 'referer: http://localhost:5173/' \
 -H 'sec-fetch-dest: empty' \
 -H 'sec-fetch-mode: cors' \
 -H 'sec-fetch-site: cross-site' \
 -H 'user-agent: Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1' \
 -H 'x-amz-user-agent: aws-amplify/6.0.27 api/1 framework/1' \
 --data-raw $'{"query":"mutation CreateAnotherTodo($input: CreateAnotherTodoInput\u0021, $condition: ModelAnotherTodoConditionInput) {\\n createAnotherTodo(input: $input, condition: $condition) {\\n id\\n name\\n description\\n createdAt\\n updatedAt\\n owner\\n \_\_typename\\n }\\n}\\n","variables":{"input":{"description":"hello","name":"hello"}}}'

I think it's also worth mentioning that your myTokenProvider function is returning both the id and access token. By default, Amplify will send the access token in the Authorization header. Can you please check the outgoing graphql requests in your Network activity and make sure that the token_use claim is what you expect? (in this case id).

Yes, I can confirm that the token sent in the GraphQL request is the same one that I placed in myTokenProvider. I can also confirm that the structure of the JWT contains the sub fields, as I mentioned.

TitusEfferian commented 5 months ago

Hi,

Recently, since Amplify Gen 2 is in stable release, I tried to implement the same use case but with Gen 2. It works well in Gen 2 because the information is clearer, thanks to the use of TypeScript configuration for constructing the backend.

I wrote an article based on my experiment in case anyone encounters this issue: How to Implement Auth0 and Discord Login in AWS Amplify Gen 2: A Step-by-Step Guide.

However, I still wonder how to achieve the same use case in Gen 1.

chrisbonifacio commented 5 months ago

@TitusEfferian thanks for sharing this guide and glad it worked in Gen 2! It should definitely work in Gen 1.

The docs link you shared in the issue description was for Gen 2. Here are the instructions for Gen 1:

https://docs.amplify.aws/gen1/react/build-a-backend/auth/advanced-workflows/#federate-with-auth0

Go through this and make sure you have not missed a step