Skip to content

RFC: Directive-based Auth #351

@ardatan

Description

@ardatan

Directives

Since Federation v2.5, three directives are introduced @authenticated, @requiresScopes and @policy

  • Granular regular protection with directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM;
    When you add this directive in your subgraph, it is directly added in the supergraph during composition. So it marks the component resolvable only if defined auth conditions met.

  • RBAC with directive @requiresScopes(scopes: [[federation__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM
    This directive becomes resolvable by the user only when the set of scopes are available in the auth payload.

AND / OR;

extend schema @link(url: "https://specs.apollo.dev/federation/v2.5", import: ["@requiresScopes"])
 
type Query {
  # This field requires the user to have `read:user` OR `read:admin` scopes
  me: User! @requiresScopes(scopes: [["read:user"], ["read:admin"]])
  # This field requires the user to have `read:user` AND `read:admin` scopes
  protectedField: String @requiresScopes(scopes: [["read:admin", "read:user"]])
  publicField: String
}
  • Dynamic RBAC with directive @policy(policies: [[federation__Policy!]!]!) on | FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM
    Same with @requiresScopes but it requires an external call/logic to fetch policies which are treated in a similar way with scopes above.
    So everytime we need to compare policies, the gateway needs to call something like below;
  fetchPolicies: async user => {
    const res = await fetch('https://policy-service.com', {
      headers: {
        Authorization: `Bearer ${user.token}`
      }
    })
    // Expected to return an array of strings
    return res.json()
  }

Example Scenarios

The scenarios below show how the gateway behaves with the directives

Scenario 1: Accessing @authenticated field when unauthorized

We have the supergraph schema below;

  extend schema
    @link(
      url: "https://specs.apollo.dev/federation/v2.5"
      import: ["@authenticated"]
    )
  type Query {
    me: User @authenticated
    post(id: ID!): Post
  }

  type User {
    id: ID!
    username: String
    email: String
    posts: [Post!]!
  }

  type Post {
    id: ID!
    author: User!
    title: String!
    content: String!
    views: Int @authenticated
  }

And we are not authorized, and trying to access Post.views and User.me with the following query;

query {
  me {
    username
  }
  post(id: "1") {
    title
    views
  }
}

In that case, except views and me, all other fields will be resolved, and the gateway will throw 2 errors with the paths and the codes.
The path in the response should point to the nullified field, locations should point to the place in the original operation's selection.

{
  data: {
    me: null,
    post: {
      title: 'Securing supergraphs',
      views: null,
    },
  },
  errors: [
    {
      message: 'Unauthorized field or type',
      path: ['me'],
      locations: [
        {
          line: 3,
          column: 17,
        },
      ],
      extensions: {
        code: 'UNAUTHORIZED_FIELD_OR_TYPE',
      },
    },
    {
      message: 'Unauthorized field or type',
      path: ['post', 'views'],
      locations: [
        {
          line: 8,
          column: 19,
        },
      ],
      extensions: {
        code: 'UNAUTHORIZED_FIELD_OR_TYPE',
      },
    },
  ],
}

Scenario 2: Accessing unauthorized field under the hood in order to resolve a public field

We have the subgraphs below that has a id field as a key which is marked as authenticated field. So in order to access this field, the client should be authorized. BUT, if it needs to be used under the hood to resolve another public field, it is ok to use it.

# Product subgraoh
extend schema
  @link(
    url: "https://specs.apollo.dev/federation/v2.5"
    import: ["@key", "@authenticated"]
  )

type Query {
  product: Product
}

type Product @key(fields: "id") {
  id: ID! @authenticated
  name: String!
  price: Int @authenticated
}
# Inventory subgraph
extend schema
  @link(
    url: "https://specs.apollo.dev/federation/v2.5"
    import: ["@key", "@authenticated"]
  )

type Product @key(fields: "id") {
  id: ID! @authenticated
  inStock: Boolean!
}

In this query, there will be no unauth error. It can successfully resolve id from Product subgraph then pass it to Inventory to get inStock. id is protected field but not exposed to the public. So it is ok to use it internally in order to resolve a public field.

query {
  product {
    name
    inStock
  }
}

Scenario 3:

Accessing id later on is not permitted still, so even if it is resolved and held, other than any public field, it will be nullified and errored with a path pointing to that private unauthorized field;

query {
  product {
    name
    inStock
  }
}

Will fail as;

{
  data: {
    product: {
      id: null,
      name: 'Couch',
    },
  },
  errors: [
    {
      message: 'Unauthorized field or type',
      path: ['product', 'id'],
      locations: [
        {
          line: 4,
          column: 19,
        },
      ],
      extensions: {
        code: 'UNAUTHORIZED_FIELD_OR_TYPE',
      },
    },
  ],
}

Scenario 4: Interfaces

Let's say we have an interface as in Post which is public but one of the implementations as in PrivateBlog.

extend schema
  @link(
    url: "https://specs.apollo.dev/federation/v2.5"
    import: ["@authenticated"]
  )

type Query {
  posts: [Post!]!
}

type User {
  id: ID!
  username: String
  posts: [Post!]!
}

interface Post {
  id: ID!
  author: User!
  title: String!
  content: String!
}

type PrivateBlog implements Post @authenticated {
  id: ID!
  author: User!
  title: String!
  content: String!
  publishAt: String
  allowedViewers: [User!]!
}

When we send the following query to the gateway, only allowedViewers will fail while other fields of the same entity are still resolved.
Because they belong to Post still while allowedViewers belong to PrivateBlog specifically. This is an important case because you should realize that id, author and title there are still from PrivateBlog type which is resolved under the hood.

query {
  posts {
    id
    author {
      username
    }
    title
    ... on PrivateBlog {
      allowedViewers {
        username
      }
    }
  }
}

So the expected result is;

{
  data: {
    posts: [
      {
        id: '1',
        author: {
          username: 'john.doe',
        },
        title: 'Securing supergraphs',
        allowedViewers: null,
      },
      {
        id: '2',
        author: {
          username: 'jane.doe',
        },
        title: 'Running supergraphs',
        allowedViewers: null,
      },
    ],
  },
  errors: [
    {
      message: 'Unauthorized field or type',
      extensions: {
        code: 'UNAUTHORIZED_FIELD_OR_TYPE',
      },
      locations: [
        {
          column: 19,
          line: 10,
        },
      ],
      path: ['posts', 'allowedViewers'],
    },
  ],
}

Scenario 5: RBAC with @requiresScopes

Let's say we have the following subgraph;

extend schema
  @link(
    url: "https://specs.apollo.dev/federation/v2.5"
    import: ["@requiresScopes"]
  )

type Query {
  user(id: ID!): User @requiresScopes(scopes: [["read:others"]])
  users: [User!]! @requiresScopes(scopes: [["read:others"]])
  post(id: ID!): Post
}

type User {
  id: ID!
  username: String
  email: String @requiresScopes(scopes: [["read:email"]])
  profileImage: String
  posts: [Post!]!
}

type Post {
  id: ID!
  author: User!
  title: String!
  content: String!
}

And we send the following query with a viewer that has read:others only.

query {
  user(id: "1") {
    username
    profileImage
    email
  }
}

email field will never be resolved, because read:email doesn't exist in the viewer's scopes;

{
  data: {
    user: {
      username: 'john.doe',
      profileImage: 'https://example.com/john.jpg',
      email: null,
    },
  },
  errors: [
    {
      message: 'Unauthorized field or type',
      extensions: {
        code: 'UNAUTHORIZED_FIELD_OR_TYPE',
      },
      path: ['user', 'email'],
      locations: [
        {
          line: 6,
          column: 19,
        },
      ],
    },
  ],
}

Gateways

Hive Gateway, Cosmo Router and Apollo Router implement those directives in the same way with some cosmetic differences such as the message in the error, the error code etc.

  • Apollo Router and Cosmo Router only support these directives with JWT Auth
  • Hive Gateway allows you to use these directives with a custom auth logic

In addition to these directives, Hive Gateway has another mode which is protect-all, so instead of protecting the fields only with @authenticated directive, Hive Gateway protects all fields and types but skips when @skipAuth directive is added.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions