GraphQL(TypeGraphQL) における認可の実装例


# 概要

[Apollo Server](https://www.apollographql.com/docs/apollo-server/v3) と [TypeGraphQL](https://typegraphql.com) を使用して GraphQL API サーバを構築しました.
その中で, 下記の通り認可の実装をする必要がありました.

- Administrator は全てのリソースに対してアクセスできること
- Owner は自己所有のリソースに対してのみアクセスできること
  - 自己所有でないリソースにアクセスできないこと

サンプルコードを通して, TypeGraphQL における認可の実装例を紹介します.

# サンプルコード

## 全体像

```bash
.
├── 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

```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

```json
{
  "compilerOptions": {
    "target": "es2020",
    "module": "commonjs",
    "lib": ["es2020"],
    "outDir": "./dist",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true
  }
}
```

## src/context.ts

Apollo Server に渡す context を実装します.

```typescript:src/context.ts
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

スキーマを定義していきましょう.

```typescript:src/schemas/models/Users.ts
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;
}
```

```typescript:src/schemas/resolvers/crud/FindUniqueUsersArgs.ts
import { ArgsType, Field } from "type-graphql";
import { UsersWhereUniqueInput } from "../inputs/UsersWhereUniqueInput";

@ArgsType()
export class FindUniqueUsersArgs {
    @Field(type => UsersWhereUniqueInput, { nullable: false })
    where!: UsersWhereUniqueInput;
}
```

```typescript:src/schemas/resolvers/crud/FindUniqueUsersResolver.ts
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

認可の実装をしていきましょう.

```typescript:src/authorizations/RolePolicy.ts
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;
};
```

```typescript:src/authorizations/Role.ts
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",
};
```

```typescript:src/authorizations/authChecker.ts
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 を登録しましょう.

```typescript:src/schema.ts
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 を渡してあげましょう.

```typescript:src/index.ts
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}`);
});
```


# サーバ起動

```bash
$ node -v
v16.18.1
$ npm -v 
8.19.2
```

```bash
$ npm start
...
🚀  Server ready at http://localhost:4000/
```

# テスト

## Administrator ロール

```:JWT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiY3VzdG9tOnVzZXJfaWQiOjEsImN1c3RvbTpyb2xlIjoiQWRtaW5pc3RyYXRvciIsImlhdCI6MTUxNjIzOTAyMn0.aP1H0BnN0VyhXUmdo2umcqsja_epigEcgSSEhGibGQM
```
```json:payload
{
  "sub": "1234567890",
  "custom:user_id": 1,
  "custom:role": "Administrator",
  "iat": 1516239022
}
```

この JWT を持つユーザは Administrator ロールのため,
全てのリソース `[{ id: 1, name: "Taro" }, { id: 2, name: "Hanako" } ]` に対してアクセスができます.

### 実行結果

```bash
$ 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"}}}
```

```bash
$ 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 ロール

```:JWT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiY3VzdG9tOnVzZXJfaWQiOjIsImN1c3RvbTpyb2xlIjoiT3duZXIiLCJpYXQiOjE1MTYyMzkwMjJ9.RcbMkhvM-4FOno0XkmJjeXCc-tBpcKJcakLO5XZaEUE
```
```json:payload
{
  "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" }` にはアクセスできません.

### 実行結果

```bash
$ 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"}}}
```

```bash
$ 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 をしていなかったり, エラーハンドリングを省いていたりするのでご注意ください.