概要
Apollo Server と TypeGraphQL を使用して GraphQL API サーバを構築しました.
その中で, 下記の通り認可の実装をする必要がありました.
- Administrator は全てのリソースに対してアクセスできること
- Owner は自己所有のリソースに対してのみアクセスできること
- 自己所有でないリソースにアクセスできないこと
サンプルコードを通して, TypeGraphQL における認可の実装例を紹介します.
サンプルコード
全体像
.
├── src
│ ├── authorizations
│ │ ├── authChecker.ts
│ │ ├── Role.ts
│ │ └── RolePolicy.ts
│ ├── schemas
│ │ ├── models
│ │ │ └── Users.ts
│ │ └── resolvers
│ │ ├── crud
│ │ │ ├── FindUniqueUsersArgs.ts
│ │ │ └── FindUniqueUsersResolver.ts
│ │ └── inputs
│ │ └── UsersWhereUniqueInput.ts
│ ├── context.ts
│ ├── index.ts
│ └── schema.ts
├── package.json
└── tsconfig.json
package.json
{
...
"scripts": {
"start": "ts-node src/index.ts"
},
"dependencies": {
"apollo-server": "^3.11.1",
"class-validator": "^0.13.2",
"graphql": "^15.8.0",
"jwt-decode": "^3.1.2",
"reflect-metadata": "^0.1.13",
"type-graphql": "^1.1.1"
},
"devDependencies": {
"@types/node": "^18.11.9",
"ts-node": "^10.9.1",
"typescript": "^4.9.3"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"lib": ["es2020"],
"outDir": "./dist",
"moduleResolution": "node",
"esModuleInterop": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true
}
}
src/context.ts
Apollo Server に渡す context を実装します.
import { ContextFunction } from "apollo-server-core";
import { ExpressContext } from "apollo-server-express";
import jwtDecode from "jwt-decode";
// 今回使用する JWT クレームの型定義
type ContextIdentifyClaims = {
sub: string;
"custom:user_id": number;
"custom:role": "Administrator" | "Owner";
iat: number;
}
type ContextIdentity = {
claims: ContextIdentifyClaims;
}
export type Context = {
identity: ContextIdentity;
}
export const context: ContextFunction<ExpressContext> = ({ req }): Context => {
const jwt = req.headers.authorization;
return {
identity: { claims: jwtDecode<ContextIdentifyClaims>(jwt ? jwt : "") },
}
};
src/schemas
スキーマを定義していきましょう.
import { ObjectType, Field, Int } from "type-graphql";
@ObjectType()
export class Users {
@Field(type => Int, { nullable: false })
id!: number;
@Field(type => String, { nullable: false })
name!: string;
}
import { ArgsType, Field } from "type-graphql";
import { UsersWhereUniqueInput } from "../inputs/UsersWhereUniqueInput";
@ArgsType()
export class FindUniqueUsersArgs {
@Field(type => UsersWhereUniqueInput, { nullable: false })
where!: UsersWhereUniqueInput;
}
import { Resolver, Authorized, Query, Args } from "type-graphql";
import { Users } from "../../models/Users";
import { Role, AdministratorRole, OwnerRole } from "../../../authorizations/Role";
import { AdministratorAccess, UsersOwnerAccess } from "../../../authorizations/RolePolicy";
import { FindUniqueUsersArgs } from "./FindUniqueUsersArgs";
@Resolver(of => Users)
export class FindUniqueUsersResolver {
private readonly users: Users[] = createUserSamples();
// src/authorizations を実装したら追加してください
@Authorized<Role>([
{ name: AdministratorRole.name, policy: AdministratorAccess },
{ name: OwnerRole.name, policy: UsersOwnerAccess }
])
@Query(returns => Users, { nullable: true })
async FindUniqueUsers(@Args() args: FindUniqueUsersArgs): Promise<Users | undefined> {
return this.users.find(user => user.id === args.where.id);
}
}
const createUserSamples = () => {
return [
createUser({
id: 1,
name: "Taro"
}),
createUser({
id: 2,
name: "Hanako",
}),
];
};
const createUser = (userData: Partial<Users>) => {
return Object.assign(new Users(), userData);
};
src/authorizations
認可の実装をしていきましょう.
import { Context } from "../context";
import { ResolverData } from "type-graphql";
export type RolePolicy<TContextType = Context> = (
resolverData: ResolverData<TContextType>,
) => boolean | Promise<boolean>;
export const AdministratorAccess: RolePolicy<Context> = () => {
// 全てのリソースに対してアクセスを許可する
return true;
};
export const UsersOwnerAccess: RolePolicy<Context> = ({ context, args }) => {
// 自己所有のリソースに対してのみアクセスを許可する
return context.identity.claims["custom:user_id"] === args.where.id;
};
import { RolePolicy } from "./RolePolicy";
export type RoleName = "Administrator" | "Owner";
export type Role = {
name: RoleName;
policy: RolePolicy;
};
export const AdministratorRole: {
name: RoleName;
} = {
name: "Administrator",
};
export const OwnerRole: {
name: RoleName;
} = {
name: "Owner",
};
import { AuthChecker } from "type-graphql";
import { Context } from "../context";
import { Role } from "./Role";
export const authChecker: AuthChecker<Context, Role> = ({ root, args, context, info }, roles: Role[]) => {
// @Authorized() に渡されたロールを決定する
const role = roles.find((role) => context.identity.claims["custom:role"].includes(role.name));
// 決定されたロールのポリシーを満たせば true を返却する
return role.policy({ root, args, context, info });
};
src/schema.ts
スキーマ構築時に, FindUniqueUsersResolver と authChecker を登録しましょう.
import { buildSchemaSync } from "type-graphql";
import { FindUniqueUsersResolver } from "./schemas/resolvers/crud/FindUniqueUsersResolver";
import { authChecker } from "./authorizations/authChecker";
export const schema = buildSchemaSync({
resolvers: [FindUniqueUsersResolver],
authChecker: authChecker,
});
src/index.ts
最後に, ApolloServer に schema と context を渡してあげましょう.
import "reflect-metadata";
import { ApolloServer } from "apollo-server";
import { schema } from "./schema";
import { context } from "./context";
const server = new ApolloServer({
schema,
context,
});
server.listen().then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});
サーバ起動
$ node -v
v16.18.1
$ npm -v
8.19.2
$ npm start
...
🚀 Server ready at http://localhost:4000/
テスト
Administrator ロール
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiY3VzdG9tOnVzZXJfaWQiOjEsImN1c3RvbTpyb2xlIjoiQWRtaW5pc3RyYXRvciIsImlhdCI6MTUxNjIzOTAyMn0.aP1H0BnN0VyhXUmdo2umcqsja_epigEcgSSEhGibGQM
{
"sub": "1234567890",
"custom:user_id": 1,
"custom:role": "Administrator",
"iat": 1516239022
}
この JWT を持つユーザは Administrator ロールのため,
全てのリソース [{ id: 1, name: "Taro" }, { id: 2, name: "Hanako" } ]
に対してアクセスができます.
実行結果
$ curl --request POST \
--header 'content-type: application/json' \
--header 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiY3VzdG9tOnVzZXJfaWQiOjEsImN1c3RvbTpyb2xlIjoiQWRtaW5pc3RyYXRvciIsImlhdCI6MTUxNjIzOTAyMn0.aP1H0BnN0VyhXUmdo2umcqsja_epigEcgSSEhGibGQM' \
--url http://localhost:4000/ \
--data '{"query":"query FindUniqueUsers($where: UsersWhereUniqueInput!) {\n FindUniqueUsers(where: $where) {\n id\n name\n }\n}","variables":{"where":{"id":1}}}'
{"data":{"FindUniqueUsers":{"id":1,"name":"Taro"}}}
$ curl --request POST \
--header 'content-type: application/json' \
--header 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiY3VzdG9tOnVzZXJfaWQiOjEsImN1c3RvbTpyb2xlIjoiQWRtaW5pc3RyYXRvciIsImlhdCI6MTUxNjIzOTAyMn0.aP1H0BnN0VyhXUmdo2umcqsja_epigEcgSSEhGibGQM' \
--url http://localhost:4000/ \
--data '{"query":"query FindUniqueUsers($where: UsersWhereUniqueInput!) {\n FindUniqueUsers(where: $where) {\n id\n name\n }\n}","variables":{"where":{"id":2}}}'
{"data":{"FindUniqueUsers":{"id":2,"name":"Hanako"}}}
いずれのリソースも無事取得できていますね.
Owner ロール
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiY3VzdG9tOnVzZXJfaWQiOjIsImN1c3RvbTpyb2xlIjoiT3duZXIiLCJpYXQiOjE1MTYyMzkwMjJ9.RcbMkhvM-4FOno0XkmJjeXCc-tBpcKJcakLO5XZaEUE
{
"sub": "1234567890",
"custom:user_id": 2,
"custom:role": "Owner",
"iat": 1516239022
}
この JWT を持つユーザは custom:user_id が 2 で Owner ロールのため,
自己所有のリソース { id: 2, name: "Hanako" }
にアクセスできますが,
自己所有でないリソース { id: 1, name: "Taro" }
にはアクセスできません.
実行結果
$ curl --request POST \
--header 'content-type: application/json' \
--header 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiY3VzdG9tOnVzZXJfaWQiOjIsImN1c3RvbTpyb2xlIjoiT3duZXIiLCJpYXQiOjE1MTYyMzkwMjJ9.RcbMkhvM-4FOno0XkmJjeXCc-tBpcKJcakLO5XZaEUE' \
--url http://localhost:4000/ \
--data '{"query":"query FindUniqueUsers($where: UsersWhereUniqueInput!) {\n FindUniqueUsers(where: $where) {\n id\n name\n }\n}","variables":{"where":{"id":2}}}'
{"data":{"FindUniqueUsers":{"id":2,"name":"Hanako"}}}
$ curl --request POST \
--header 'content-type: application/json' \
--header 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiY3VzdG9tOnVzZXJfaWQiOjIsImN1c3RvbTpyb2xlIjoiT3duZXIiLCJpYXQiOjE1MTYyMzkwMjJ9.RcbMkhvM-4FOno0XkmJjeXCc-tBpcKJcakLO5XZaEUE' \
--url http://localhost:4000/ \
--data '{"query":"query FindUniqueUsers($where: UsersWhereUniqueInput!) {\n FindUniqueUsers(where: $where) {\n id\n name\n }\n}","variables":{"where":{"id":1}}}'
{"errors":[{"message":"Access denied! You don't have permission for this action!","locations":[{"line":2,"column":3}],"path":["FindUniqueUsers"],"extensions":{"code":"INTERNAL_SERVER_ERROR","exception":{"stacktrace":["Error: Access denied! You don't have permission for this action!"," at /.../node_modules/type-graphql/dist/helpers/auth-middleware.js:13:79"," at processTicksAndRejections (node:internal/process/task_queues:96:5)"," at async dispatchHandler (/.../node_modules/type-graphql/dist/resolvers/helpers.js:82:24)"]}}}],"data":{"FindUniqueUsers":null}}
{ id: 2, name: "Hanako" }
は取得できましたが, { id: 1, name: "Taro" }
は取得できませんでした.
いずれも期待通りの振る舞いですね.
最後に
TypeGraphQL における認可の実装例を紹介しました.
サンプルコードのため, JWT の verify をしていなかったり, エラーハンドリングを省いていたりするのでご注意ください.