Skip to content

Commit

Permalink
feat: Support Authentication in Realtime Subscriptions (#8815)
Browse files Browse the repository at this point in the history
This PR implements validator directives on subscriptions like
`@requireAuth` so one may enabled authentication on a Subscription just
like one does for Queries and Mutation in RedwoodJS's GraphQL.

It also:

* enables any validator directive on Subscriptions (but not transformer
ones)
* checks that subscriptions have the `@skipAuth` or `@requireAuth`
directive (as do queries and mutations)
* updates the subscription templates to include `@requireAuth`

For authorization (that it is I can Subscribe to newMessage -- authn --
but not listen to changes to room 227 -- authz -- one can do something
like where you check for a user and the room and raise if no access
rights:

```ts
const newMessage = {
  newMessage: {
    subscribe: (
      _,
      { roomId },
      {
        pubSub,
        currentUser,
      }: { pubSub: NewMessageChannelType; currentUser: Record<string, any> }
    ) => {
      if (currentUser?.id === '1234' && roomId === '227') {
        throw new ForbiddenError('no can do')
      }

      logger.debug({ roomId }, 'newMessage subscription')

      return pubSub.subscribe('newMessage', roomId)
    },
...
```


TODO:

* docs
* docs for directives and how Subscriptions cannot have transformer
directives
* see maybe if Subs can transformer directives -- otherwise see if need
to warn better
  • Loading branch information
dthyresson authored Jul 4, 2023
1 parent 589e87a commit a471d26
Show file tree
Hide file tree
Showing 10 changed files with 51 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

export const schema = gql`
type Query {
auction(id: ID!): Auction @skipAuth
auction(id: ID!): Auction @requireAuth
}

type Auction {
Expand All @@ -17,7 +17,7 @@ export const schema = gql`
}

type Mutation {
bid(input: BidInput!): Bid @skipAuth
bid(input: BidInput!): Bid @requireAuth
}

input BidInput {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// api/src/services/auctions/auctions.ts
import type { LiveQueryStorageMechanism } from '@redwoodjs/graphql-server'

import { logger } from 'src/lib/logger'

Expand Down Expand Up @@ -40,7 +41,10 @@ export const auction = async ({ id }) => {
return foundAuction
}

export const bid = async ({ input }, { context }) => {
export const bid = async (
{ input },
{ context }: { context: { liveQueryStore: LiveQueryStorageMechanism } }
) => {
const { auctionId, amount } = input

const index = auctions.findIndex((a) => a.id === auctionId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

export const schema = gql`
type Query {
${liveQueryName}(id: ID!): ${typeName} @skipAuth
${liveQueryName}(id: ID!): ${typeName} @requireAuth
}

type ${typeName} {
Expand All @@ -17,7 +17,7 @@ export const schema = gql`
}

type Mutation {
create${typeName}Item(input: ${typeName}ItemInput!): ${typeName}Item @skipAuth
create${typeName}Item(input: ${typeName}ItemInput!): ${typeName}Item @requireAuth
}

input ${typeName}ItemInput {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// api/src/services/${name}s/${name}s.ts
import type { LiveQueryStorageMechanism } from '@redwoodjs/graphql-server'

import { logger } from 'src/lib/logger'

Expand Down Expand Up @@ -40,7 +41,10 @@ export const ${liveQueryName} = async ({ id }) => {
return found${modelName}
}

export const create${typeName}Item = async ({ input }, { context }) => {
export const create${typeName}Item = async (
{ input },
{ context }: { context: { liveQueryStore: LiveQueryStorageMechanism } }
) => {
const { ${camelName}Id, amount } = input

const index = ${collectionName}.findIndex((a) => a.id === ${camelName}Id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { logger } from 'src/lib/logger'

export const schema = gql`
type Subscription {
${subscriptionName}(id: ID!): ${typeName}!
${subscriptionName}(id: ID!): ${typeName}! @requireAuth
}
`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import gql from 'graphql-tag'

export const schema = gql`
type Subscription {
countdown(from: Int!, interval: Int!): Int!
countdown(from: Int!, interval: Int!): Int! @requireAuth
}
`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { logger } from 'src/lib/logger'

export const schema = gql`
type Subscription {
newMessage(roomId: ID!): Message!
newMessage(roomId: ID!): Message! @requireAuth
}
`
export type NewMessageChannel = {
Expand Down
3 changes: 2 additions & 1 deletion packages/graphql-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ export {
export {
useRedwoodRealtime,
createPubSub,
InMemoryLiveQueryStore,
liveDirectiveTypeDefs,
InMemoryLiveQueryStore,
LiveQueryStorageMechanism,
RedisLiveQueryStore,
RedwoodRealtimeOptions,
PublishClientType,
Expand Down
26 changes: 26 additions & 0 deletions packages/graphql-server/src/plugins/useRedwoodDirective.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,11 @@ function wrapAffectedResolvers(
if (directiveNode && directive) {
const directiveArgs =
getDirectiveValues(directive, { directives: [directiveNode] }) || {}

const originalResolve = fieldConfig.resolve ?? defaultFieldResolver
// Only validator directives handle a subscribe function
const originalSubscribe = fieldConfig.subscribe ?? defaultFieldResolver

if (_isValidator(options)) {
return {
...fieldConfig,
Expand All @@ -180,6 +184,28 @@ function wrapAffectedResolvers(
}
return originalResolve(root, args, context, info)
},
subscribe: function useRedwoodDirectiveValidatorResolver(
root,
args,
context,
info
) {
const result = options.onResolvedValue({
root,
args,
context,
info,
directiveNode,
directiveArgs,
})

if (isPromise(result)) {
return result.then(() =>
originalSubscribe(root, args, context, info)
)
}
return originalSubscribe(root, args, context, info)
},
}
}
if (_isTransformer(options)) {
Expand Down
8 changes: 6 additions & 2 deletions packages/internal/src/validateSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const DIRECTIVE_INVALID_ROLE_TYPES_ERROR_MESSAGE =
'Please check that the requireAuth roles is a string or an array of strings.'
export function validateSchemaForDirectives(
schemaDocumentNode: DocumentNode,
typesToCheck: string[] = ['Query', 'Mutation']
typesToCheck: string[] = ['Query', 'Mutation', 'Subscription']
) {
const validationOutput: string[] = []
const directiveRoleValidationOutput: Record<string, any> = []
Expand Down Expand Up @@ -106,7 +106,11 @@ export function validateSchemaForDirectives(

export const loadAndValidateSdls = async () => {
const projectTypeSrc = await loadTypedefs(
['graphql/**/*.sdl.{js,ts}', 'directives/**/*.{js,ts}'],
[
'graphql/**/*.sdl.{js,ts}',
'directives/**/*.{js,ts}',
'subscriptions/**/*.{js,ts}',
],
{
loaders: [
new CodeFileLoader({
Expand Down

0 comments on commit a471d26

Please sign in to comment.