GitLab as an OAuth2 provider: Web application flowを実装する

この記事は[ユニークビジョン株式会社 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/uniquevision)の15日目の記事です。

## 概要
GitLabによるOAuth認証を、ReactとGolangを使って実装する。
認証が完了した際に、そのユーザの情報をブラウザ表示するアプリを実装する。

## Document
[GitLab as an OAuth2 provider](https://docs.gitlab.com/ee/api/oauth2.html#supported-oauth2-flows)

## 事前準備
User settings -> Applications内でOAuth認証に使うアプリを作成しておく

## 認証の流れ
### 1. https://gitlab.example.com/oauth/authorize へリクエストを送る

この際に幾つかパラメータを付けてリクエストを行う

- client_id
    - GitLabアプリのApplication ID
- redirect_uri
    - アプリの利用を承認した後にリダイレクトするURI
    - 事前にアプリの設定でcallback uriを追加しておくこと
- response_type
    - あまり理解していないが、OAuth認証の方式によって異なる模様
    - 今回は`code`を指定する
- state
    - CSRF攻撃を防ぐために指定する
    - 認証リクエスト毎にユニークであることが望ましい
    - この記事では面倒だったため一意に`hoge`で設定している
- scope
    - この認証で許可する操作を指定する
    - アプリで設定した権限以上は設定できないと思われる

### 2. GitLabに遷移後、許可、Redirect uriへリダイレクト
`http://myapp.com/oauth/redirect?code=1234567890&state=YOUR_UNIQUE_STATE_HASH`

以上のような形でリダイレクトされる。
この記事では、React側にリダイレクトさせて、クエリをパースし、こちらで用意したサーバに送る。

### 3. https://gitlab.example.com/oauth/token へcodeとstateを送る
この記事ではサーバでcode, stateを受け取り、リクエストを行う。
その後、発行されたAccess tokenを使ってUser情報を取得し、React側へ返している。

## サーバーサイド
### Go
#### レポジトリ
[gitlab](https://gitlab.com/iwatani_keita/glaboauth)

### ソースコード
``` go:main.go
package main

import (
	"encoding/json"
	"errors"
	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
	"io/ioutil"
	"log"
	"net/http"
	"net/url"
	"strings"
	"time"
)

type Config struct {
	Port        string
	DbHost      string
	ClientID    string
	AppSecret   string
	RedirectURI string
}

func main() {
	config := get_config()
	r := gin.Default()
	// Cors
	r.Use(cors.New(cors.Config{
		AllowOrigins:     []string{"*"},
		AllowMethods:     []string{"GET", "POST", "PUT", "PATCH", "OPTIONS"},
		AllowHeaders:     []string{"Origin"},
		ExposeHeaders:    []string{"Content-Length"},
		AllowCredentials: true,
		AllowOriginFunc: func(origin string) bool {
			return true
		},
		MaxAge: 12 * time.Hour,
	}))
	// Routing
	r.POST("/oauth", oauth)
	r.Run(":" + config.Port)
}

func get_config() Config {
	return Config{
		Port:        "8000",
		ClientID:    "xxxxx",
		AppSecret:   "xxxxx",
		RedirectURI: "http://127.0.0.1:3000/auth",
	}
}

// クライアント側で認証を許可すると呼ばれるエンドポイント
func oauth(c *gin.Context) {
	c.Request.ParseForm()
	// callback urlに併せて付いてくるcodeとstateを使ってパラメータを組み立てる
	code := strings.Join(c.Request.Form["code"], "")
	state := strings.Join(c.Request.Form["state"], "")
	oauth, err := oauth_token(code, state)
	if err != nil {
		c.JSON(400, gin.H{
			"error": err.Error(),
		})
		return
	}
	// 認証したユーザでAPIをコールする
	user, err := get_user(oauth.AccessToken)
	if err != nil {
		c.JSON(400, gin.H{
			"error": err.Error(),
		})
		return
	}
	c.JSON(200, user)
}

func oauth_token(code string, state string) (OAuth, error) {
	config := get_config()
	endpoint := "https://gitlab.com/oauth/token"

	// HTTPクライアントを作成
	client := &http.Client{}
	client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	}
	values := url.Values{}
	// callback urlに併せて付いてくるcodeとstateを使ってパラメータを組み立てる
	values.Add("client_id", config.ClientID)
	values.Add("client_secret", config.AppSecret)
	values.Add("code", code)
	values.Add("state", state)
	values.Add("grant_type", "authorization_code")
	values.Add("redirect_uri", config.RedirectURI)

	// HTTPリクエストを作成
	req, err := http.NewRequest("POST", endpoint, strings.NewReader(values.Encode()))
	if err != nil {
		return OAuth{}, errors.New(err.Error())
	}

	// リクエストを実行
	resp, err := client.Do(req)
	if err != nil {
		return OAuth{}, errors.New(err.Error())
	}
	defer resp.Body.Close()

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return OAuth{}, errors.New(err.Error())
	}
	// JSONをパース
	var oauth OAuth
	if err := json.Unmarshal(body, &oauth); err != nil {
		return OAuth{}, errors.New(err.Error())
	}
	return oauth, nil
}

type OAuth struct {
	AccessToken string `json:"access_token"`
}

type User struct {
	Id        int    `json:"id"`
	Username  string `json:"username"`
	Name      string `json:"name"`
	AvatarUrl string `json:"avatar_url"`
}

// 取得したトークンでユーザ情報を取得
func get_user(token string) (User, error) {
	endpoint := "https://gitlab.com/api/v4/user"

	// HTTPクライアントを作成
	client := &http.Client{}
	client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	}

	// HTTPリクエストを作成
	req, err := http.NewRequest("GET", endpoint, nil)

	// Bearerトークンを指定
	req.Header.Add("Authorization", "Bearer "+token)
	if err != nil {
		return User{}, errors.New(err.Error())
	}

	// リクエスト実行
	resp, err := client.Do(req)
	if err != nil {
		return User{}, errors.New(err.Error())
	}
	defer resp.Body.Close()

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return User{}, errors.New(err.Error())
	}

	// JSONをパース
	var user User
	if err := json.Unmarshal(body, &user); err != nil {
		log.Fatal(err)
	}
	return user, nil
}

```

## クライアント
### React
#### レポジトリ
[gitlab](https://gitlab.com/iwatani_keita/glaboauth-react)

### ソースコード
``` js:index.js
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

ReactDOM.render(
  <App />,
  document.getElementById('root')
)
```

``` js:App.js
import React, { Component } from 'react'
import { BrowserRouter, Route, Link } from 'react-router-dom';
import queryString from 'query-string';
import Auth from './auth';
import Root from './root';

const App = () => (
  <BrowserRouter>
    <div>
      <Route exact path='/' component={Root} />
      <Route path='/auth' render={ (props) => <Auth qs={queryString.parse(props.location.search)} />} />
    </div>
  </BrowserRouter>
)

export default App
```

``` jsx:root.jsx
import React, { useState, useEffect } from 'react';

const appId = "xxxxx";
const scope = "api+profile+read_user";
const redirect_uri = encodeURI("http://127.0.0.1:3000/auth");
const oauth_url = "https://gitlab.com/oauth/authorize?client_id=" + appId + "&redirect_uri=" + redirect_uri + "&response_type=code&state=hoge&scope=" + scope;

// GitLabへのリンクを作成する
// 認証が完了するとredirect_uriに指定したエンドポイントにリダイレクトする
// このサンプルでは同Reactアプリの/authに遷移する
let Root = (props) => {
  return (
    <div>
      <h2>Home</h2>
      <p>Welcome to test oauth</p>
      <a href={oauth_url}><button>Sign in with GitLab</button></a>
    </div>
  );
}

export default Root
```

``` jsx:auth.jsx
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'
import axios from 'axios';

// こちらで用意したサーバのエンドポイント
const url = "http://127.0.0.1:8000" + "/oauth";

// OAuth認証承認後、このページに遷移する
let Auth = (props) => {
  const [user, setUser] = useState({
    id: null,
    username: null,
    avatar_url: null,
    name: null
  });

  const qs = props.qs;

// このコンポーネントがマウントされたときにサーバへGitLabが発行したcode, stateを送る
let Auth = (props) => {
  useEffect(() => {
    getAuth()
  }, []);

  // このコンポーネントがマウントされたときにサーバへGitLabが発行したcode, stateを送る
  let getAuth = () => {
    let params = new URLSearchParams();

    // redirect uriにクエリパラメータとして付いているcodeとstateをサーバへ送る
    params.append('code', qs.code);
    params.append('state', qs.state);
    axios.post(url, params)
    .then(response => {
      setUser(response.data);
    }).catch(error => {
      console.log(error);
    });
  }

  // 発行されたトークンを使って取得したユーザ情報を表示する
  return (
    <div>
      <img border="0" src={user.avatar_url} width="128" height="128" alt="avatar"></img>
      <h2>ID</h2>
      <p>{user.id}</p>
      <h2>Username</h2>
      <p>{user.username}</p>
      <h2>name</h2>
      <p>{user.name}</p>
      <Link to="/">Go back Home</Link>
    </div>
  );
}

export default Auth
```

## 動作確認
### サーバ起動
goプロジェクトのルートで以下のコマンドを実行`localhost:8000`でサーバが起動する

```bash
go run main.go
```

### クライアント準備
Reactプロジェクトのルートで以下のコマンドを実行`localhost:3000`でブラウザが起動する

```bash
npm start
```

### 実際に認証してみる
`localhost:3000`にアクセスすると以下のような画面が表示される。

![Screenshot from 2019-12-16 00-26-38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314367/49017a9d-fa14-90fb-7e45-e006c5962feb.png)

`Sign in with GitLab`を押下すると以下のような画面が表示される。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314367/0ff9b02e-42eb-849a-a40c-5f3aa79980de.png)


`Authorize` を押下すると、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314367/d97c6b22-799b-aa84-397b-1d266076777e.png)

## まとめ
認証フロー自体はかなりシンプルで分かりやすいものだが、Documentの例で、`/oauth/authorize`に対するリクエストの形式がhttpになっており、この形式が間違っているとエラーすら出ず、サインイン画面にリダイレクトされるのはなかなか分かりづらかった。

社内ではGitLabを中心とした業務フローになりつつあるので、このOAuth認証を使ってツールを構築していければと考えている。