Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(graphql): add allowUnauthenticated parameter to auth rules #355

Merged
merged 10 commits into from
Jul 28, 2021
23 changes: 23 additions & 0 deletions docs/asciidoc/auth/authentication.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,26 @@ type Todo {

extend type Todo @auth(rules: [{ isAuthenticated: true }])
----

== `allowUnauthenticated`
In some cases, you may want to allow unauthenticated requests while also having auth-based rules. You can use the `allowUnauthenticated` parameter to avoid throwing an exception if no auth is present in the context.

In the example below, only the publisher can see his blog posts if it is not published yet. Once the blog post is published, anyone can see it.

[source, graphql]
----
type BlogPost
@auth(
rules: [
{
operations: [READ]
where: { OR: [{ publisher: "$jwt.sub" }, { published: true }] }
allowUnauthenticated: true
}
]
) {
id: ID!
publisher: String!
published: Boolean!
}
----
2 changes: 1 addition & 1 deletion packages/graphql/src/schema/get-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { DirectiveNode, valueFromASTUntyped } from "graphql";
import { Auth, AuthRule, AuthOperations } from "../types";

const validOperations: AuthOperations[] = ["CREATE", "READ", "UPDATE", "DELETE", "CONNECT", "DISCONNECT"];
const validFields = ["operations", "AND", "OR", "allow", "where", "bind", "isAuthenticated", "roles"];
const validFields = ["operations", "AND", "OR", "allow", "where", "bind", "isAuthenticated", "allowUnauthenticated", "roles"];

function getAuth(directive: DirectiveNode): Auth {
const auth: Auth = { rules: [], type: "JWT" };
Expand Down
154 changes: 146 additions & 8 deletions packages/graphql/src/translate/create-auth-and-params.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ describe("createAuthAndParams", () => {

expect(trimmer(result[0])).toEqual(
trimmer(`
EXISTS(this.id) AND this.id = $this_auth_allow0_id OR ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))
this.id IS NOT NULL AND this.id = $this_auth_allow0_id OR ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))
`)
);

Expand Down Expand Up @@ -175,7 +175,7 @@ describe("createAuthAndParams", () => {

expect(trimmer(result[0])).toEqual(
trimmer(`
EXISTS(this.id) AND this.id = $this_auth_allow0_id OR ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))
this.id IS NOT NULL AND this.id = $this_auth_allow0_id OR ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))
`)
);

Expand Down Expand Up @@ -253,7 +253,7 @@ describe("createAuthAndParams", () => {

expect(trimmer(result[0])).toEqual(
trimmer(`
ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr)) AND EXISTS(this.id) AND this.id = $this_auth_allow0_id
ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr)) AND this.id IS NOT NULL AND this.id = $this_auth_allow0_id
`)
);

Expand Down Expand Up @@ -334,7 +334,7 @@ describe("createAuthAndParams", () => {

expect(trimmer(result[0])).toEqual(
trimmer(`
EXISTS(this.id) AND this.id = $this${key}0_auth_allow0_id ${key} ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))
this.id IS NOT NULL AND this.id = $this${key}0_auth_allow0_id ${key} ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))
`)
);

Expand Down Expand Up @@ -422,11 +422,11 @@ describe("createAuthAndParams", () => {
trimmer(`
ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))
AND
EXISTS(this.id) AND this.id = $this_auth_allow0_id
this.id IS NOT NULL AND this.id = $this_auth_allow0_id
AND
EXISTS(this.id) AND this.id = $thisAND0_auth_allow0_id AND ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))
this.id IS NOT NULL AND this.id = $thisAND0_auth_allow0_id AND ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))
AND
EXISTS(this.id) AND this.id = $thisOR0_auth_allow0_id OR ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))
this.id IS NOT NULL AND this.id = $thisOR0_auth_allow0_id OR ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))
`)
);

Expand Down Expand Up @@ -536,7 +536,7 @@ describe("createAuthAndParams", () => {

expect(trimmer(result[0])).toEqual(
trimmer(`
(EXISTS(this.id) AND this.id = $this_auth_allow0_${key}0_id ${key} EXISTS(this.id) AND this.id = $this_auth_allow0_${key}1_id ${key} EXISTS(this.id) AND this.id = $this_auth_allow0_${key}2_id)
(this.id IS NOT NULL AND this.id = $this_auth_allow0_${key}0_id ${key} this.id IS NOT NULL AND this.id = $this_auth_allow0_${key}1_id ${key} this.id IS NOT NULL AND this.id = $this_auth_allow0_${key}2_id)
`)
);

Expand Down Expand Up @@ -685,5 +685,143 @@ describe("createAuthAndParams", () => {
});
}).toThrow("Unauthenticated");
});

test("should showcase the allowUnauthenticated behavior with undefined $jwt", () => {
const idField = {
fieldName: "id",
typeMeta: {
name: "ID",
array: false,
required: false,
pretty: "String",
input: {
where: {
type: "String",
pretty: "String",
},
create: {
type: "String",
pretty: "String",
},
update: {
type: "String",
pretty: "String",
},
},
},
otherDirectives: [],
arguments: [],
};

// @ts-ignore
const node: Node = {
name: "Movie",
relationFields: [],
cypherFields: [],
enumFields: [],
scalarFields: [],
primitiveFields: [idField],
dateTimeFields: [],
interfaceFields: [],
objectFields: [],
pointFields: [],
authableFields: [idField],
auth: {
rules: [
{ allow: { id: "$jwt.sub" }, allowUnauthenticated: true },
{ operations: ["CREATE"], roles: ["admin"] },
{ roles: ["admin"] },
],
type: "JWT",
},
};

// @ts-ignore
const neoSchema: Neo4jGraphQL = {
nodes: [node],
};

// @ts-ignore
const context: Context = { neoSchema, jwt: {} };

const result = createAuthAndParams({
context,
entity: node,
operation: "READ",
allow: { parentNode: node, varName: "this" },
});

expect(trimmer(result[0])).toEqual(trimmer('false OR ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))'));
expect(result[1]).toEqual({});
mathix420 marked this conversation as resolved.
Show resolved Hide resolved
});

test("should showcase the allowUnauthenticated behavior with undefined $context", () => {
const idField = {
fieldName: "id",
typeMeta: {
name: "ID",
array: false,
required: false,
pretty: "String",
input: {
where: {
type: "String",
pretty: "String",
},
create: {
type: "String",
pretty: "String",
},
update: {
type: "String",
pretty: "String",
},
},
},
otherDirectives: [],
arguments: [],
};

// @ts-ignore
const node: Node = {
name: "Movie",
relationFields: [],
cypherFields: [],
enumFields: [],
scalarFields: [],
primitiveFields: [idField],
dateTimeFields: [],
interfaceFields: [],
objectFields: [],
pointFields: [],
authableFields: [idField],
auth: {
rules: [
{ allow: { id: "$context.nop" }, allowUnauthenticated: true },
{ operations: ["CREATE"], roles: ["admin"] },
{ roles: ["admin"] },
],
type: "JWT",
},
};

// @ts-ignore
const neoSchema: Neo4jGraphQL = {
nodes: [node],
};

// @ts-ignore
const context: Context = { neoSchema, jwt: {} };

const result = createAuthAndParams({
context,
entity: node,
operation: "READ",
allow: { parentNode: node, varName: "this" },
});

expect(trimmer(result[0])).toEqual(trimmer('false OR ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))'));
expect(result[1]).toEqual({});
mathix420 marked this conversation as resolved.
Show resolved Hide resolved
});
});
});
34 changes: 25 additions & 9 deletions packages/graphql/src/translate/create-auth-and-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ function createAuthPredicate({
}

const { jwt } = context;
const { allowUnauthenticated } = rule;

const result = Object.entries(rule[kind] as any).reduce(
(res: Res, [key, value]) => {
Expand All @@ -75,7 +76,10 @@ function createAuthPredicate({

(value as any[]).forEach((v, i) => {
const authPredicate = createAuthPredicate({
rule: { [kind]: v } as AuthRule,
rule: {
[kind]: v,
allowUnauthenticated
} as AuthRule,
varName,
node,
chainStr: `${chainStr}_${key}${i}`,
Expand All @@ -92,8 +96,8 @@ function createAuthPredicate({

const authableField = node.authableFields.find((field) => field.fieldName === key);
if (authableField) {
const [, jwtPath] = (value as string).split("$jwt.");
const [, ctxPath] = (value as string).split("$context.");
const [, jwtPath] = (value as string)?.split?.("$jwt.") || [];
const [, ctxPath] = (value as string)?.split?.("$context.") || [];
let paramValue: string | null = value as string;

if (jwtPath) {
Expand All @@ -102,13 +106,19 @@ function createAuthPredicate({
paramValue = dotProp.get({ value: context }, `value.${ctxPath}`) as string;
}

if (paramValue === undefined) {
if (paramValue === undefined && allowUnauthenticated !== true) {
throw new Neo4jGraphQLAuthenticationError("Unauthenticated");
}

const param = `${chainStr}_${key}`;
res.params[param] = paramValue;
res.strs.push(`EXISTS(${varName}.${key}) AND ${varName}.${key} = $${param}`);
if (paramValue === undefined) {
res.strs.push("false");
} else if (paramValue === null) {
res.strs.push(`${varName}.${key} IS NULL`);
} else {
const param = `${chainStr}_${key}`;
res.params[param] = paramValue;
res.strs.push(`${varName}.${key} IS NOT NULL AND ${varName}.${key} = $${param}`);
}
}

const relationField = node.relationFields.find((x) => key === x.fieldName);
Expand All @@ -134,7 +144,10 @@ function createAuthPredicate({
context,
chainStr: `${chainStr}_${key}`,
varName: relationVarName,
rule: { [kind]: { [k]: v } } as AuthRule,
rule: {
[kind]: { [k]: v },
allowUnauthenticated
} as AuthRule,
kind,
});
resultStr += authPredicate[0];
Expand Down Expand Up @@ -192,7 +205,10 @@ function createAuthAndParams({
}

const authWhere = createAuthPredicate({
rule: { where: authRule.where },
rule: {
where: authRule.where,
allowUnauthenticated: authRule.allowUnauthenticated
},
context,
node: where.node,
varName: where.varName,
Expand Down
1 change: 1 addition & 0 deletions packages/graphql/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export interface Context {

export interface BaseAuthRule {
isAuthenticated?: boolean;
allowUnauthenticated?: boolean;
allow?: { [k: string]: any } | "*";
bind?: { [k: string]: any } | "*";
where?: { [k: string]: any } | "*";
Expand Down
Loading