Skip to content

Commit 3c068e4

Browse files
committed
AuthPlugin
1 parent 665dd46 commit 3c068e4

File tree

3 files changed

+134
-2
lines changed

3 files changed

+134
-2
lines changed

src/AuthPlugin.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { DocumentNode, ExecutionArgs, GraphQLSchema, defaultFieldResolver, DirectiveNode } from "graphql";
2+
import { SchemaTransform, mapSchema, MapperKind, getDirectives } from "@graphql-tools/utils";
3+
4+
import { GraphQLPlugin, Executable, MaybePromise } from ".";
5+
import gql from "graphql-tag";
6+
import { AuthorizationError } from "./errors";
7+
import { ServiceContext } from "./GraphQLServer";
8+
9+
export type AuthorizationLevelExtractor = (ctx: ServiceContext) => MaybePromise<AuthorizationLevel>;
10+
11+
export interface AuthPluginOptions {
12+
defaultLevel?: AuthorizationLevel;
13+
levelExtractor: AuthorizationLevelExtractor;
14+
}
15+
16+
export type AuthorizationLevel = "PUBLIC" | "USER" | "ADMIN" | "GOD";
17+
18+
const authorizationLevels: Record<AuthorizationLevel, number> = {
19+
PUBLIC: 0,
20+
USER: 1,
21+
ADMIN: 2,
22+
GOD: 99
23+
};
24+
25+
interface AuthPluginContext extends ServiceContext {
26+
authPlugin: {
27+
level: AuthorizationLevel;
28+
}
29+
}
30+
31+
export class AuthPlugin implements GraphQLPlugin {
32+
constructor(private readonly options: AuthPluginOptions) {
33+
if (!options.defaultLevel) {
34+
options.defaultLevel = "PUBLIC";
35+
}
36+
}
37+
38+
directives(): (string | DocumentNode)[] {
39+
return [gql`
40+
enum AuthorizationLevel {
41+
PUBLIC
42+
USER
43+
ADMIN
44+
GOD
45+
}
46+
directive @a11n(level: AuthorizationLevel) on FIELD_DEFINITION
47+
directive @authorization(level: AuthorizationLevel) on FIELD_DEFINITION
48+
`];
49+
}
50+
51+
transforms(): SchemaTransform[] {
52+
return [
53+
(schema: GraphQLSchema) => mapSchema(schema, {
54+
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
55+
const directives = getDirectives(schema, fieldConfig);
56+
if ("authorization" in directives || "a11n" in directives) {
57+
const { resolve = defaultFieldResolver } = fieldConfig;
58+
59+
fieldConfig.resolve = async (source, args, context, info) => {
60+
const authContext: AuthPluginContext = context;
61+
const requiredLevel = levelFromDirectives(fieldConfig.astNode?.directives);
62+
if (hasAccess(authContext, requiredLevel)) {
63+
return resolve(source, args, context, info);
64+
} else {
65+
throw new AuthorizationError();
66+
}
67+
};
68+
}
69+
return fieldConfig;
70+
}
71+
})
72+
];
73+
}
74+
75+
wrapper = (next: Executable): Executable => {
76+
return async (args: ExecutionArgs) => {
77+
const context: AuthPluginContext = args.contextValue;
78+
const level = await this.options.levelExtractor(context);
79+
context.authPlugin = { level };
80+
return next(args);
81+
};
82+
};
83+
}
84+
85+
function levelFromDirectives(
86+
directives: ReadonlyArray<DirectiveNode> | undefined,
87+
): AuthorizationLevel {
88+
if (!directives) return "PUBLIC";
89+
90+
const authLevelDirective = directives.find(
91+
(directive) => (
92+
directive.name.value === "authorization" ||
93+
directive.name.value === "a11n"
94+
)
95+
);
96+
if (!authLevelDirective?.arguments) return "PUBLIC";
97+
98+
const levelArgument = authLevelDirective.arguments.find(
99+
argument => argument.name.value === "level",
100+
);
101+
102+
const level = levelArgument?.value?.kind === "EnumValue"
103+
? levelArgument.value.value ?? "PUBLIC"
104+
: "PUBLIC";
105+
106+
if (!(isAuthorizationLevel(level))) {
107+
throw new Error();
108+
}
109+
return level;
110+
}
111+
112+
function isAuthorizationLevel(maybe: string): maybe is AuthorizationLevel {
113+
return maybe in authorizationLevels;
114+
}
115+
116+
function hasAccess(context: AuthPluginContext | undefined, required: AuthorizationLevel): boolean {
117+
const currentLevel = context?.authPlugin?.level ?? "PUBLIC";
118+
return authorizationLevels[currentLevel] >= authorizationLevels[required];
119+
}
120+
121+
export default AuthPlugin;

src/errors.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ export class ClientFacingError extends Error {
1010
}
1111
}
1212

13+
export class AuthorizationError extends ClientFacingError {
14+
readonly code = 403;
15+
16+
constructor() {
17+
super("No access");
18+
}
19+
}
20+
1321
/**
1422
* ExtendedError adds service specific error data to the `extensions`
1523
* response field.

src/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
export { IResolvers } from "@graphql-tools/utils";
2+
23
export * from "./GraphQLServer";
34
export * from "./GraphQLPlugin";
5+
export * from "./errors";
6+
7+
export * from "./AuthPlugin";
48
export * from "./CachePlugin";
5-
export * from "./TracePlugin";
69
export * from "./LoggerPlugin";
7-
export * from "./errors";
10+
export * from "./TracePlugin";

0 commit comments

Comments
 (0)