Login with NotionをSinatraで実装する 

主に個人向けで作っているサービスでLogin with Notionを実装する必要があったので、その時の動作検証を行った時の作業ログです。
Login with Notionと仰々しく言っていますが、端的に言えばNotionと連携するためにOAuthを利用するだけの話です。

Public Integrationを作成する

Notion公式ドキュメントに従って、Public Integrationを作成します。

developers.notion.com

今回の動作検証時のIntegrationの設定は、以下のようになっています。
適当と書いてあるところは適当に値を設定しています。

  • Basic Information
    • Name ... 適当
  • Capabilities
    • Content Capabilities ... Read content
    • User Capabilities ... Read user information including email addresses
  • Organization Information
    • Company name ... 適当
    • Website or homepage ... 適当
    • Privacy policy ... 適当
    • Terms of use ... 適当
    • Support email ... 適当
  • OAuth Domain & URIs
    • Redirect URIs ... 動作確認のためにhttp://localhost:3000/callback

実装

Sinatraを使って簡易的に検証します。
今回は使っていないですが、OmniAuthが使える状況であればomniauth-notionというGemが存在するので、それを使うと楽かもしれません。

Gem

Gemfileは以下のようにSinatraを使うためにsinatrapumaを導入し、Sinatra::Reloaderを使いたいのでsinatra-contribも導入しておきます。
APIリクエストはNet::HTTPを使いますが、Faradayなどを使った方が間違いなく楽なのでお好みで使ってください。

# frozen_string_literal: true

source "https://rubygems.org"

gem 'sinatra'
gem 'sinatra-contrib'
gem 'puma'

仕様

本格的な実装の前に今回作成するアプリケーションの大まかな仕様を決めておきます。 

  • Endpoints
    • / ... ログインしているユーザーの名前を表示。ログインしていない場合は/loginへリダイレクト
    • /login ... Public IntegrationのAuthorization URLのリンクを配置
    • /logout ... セッションをクリアし、/へリダイレクト
    • /callback ... Authorization URLアクセス後に呼び出されるコールバックなので、渡された情報を元にアクセストークンとセッションの生成を行う
  • Credentials
    • Client ID ... NOTION_CLIENT_IDという環境変数に設定
    • Client Secret ... NOTION_CLINET_SECRETという環境変数に設定

Sinatraの設定

app.rbというファイルを作成して、Sinatraの設定を書きます。

# frozen_string_literal: true

require 'uri'
require 'net/http'
require 'sinatra'
require 'sinatra/reloader'

class Application < Sinatra::Base
  CLIENT_ID = ENV['NOTION_CLIENT_ID']
  CLIENT_SECRET = ENV['NOTION_CLIENT_SECRET']
  REDIRECT_URI = 'http://localhost:3000/callback' # Notionで設定したRedirect URLsと同じである必要アリ

  configure do
    # セッションを有効化
    # Cookieストアなので注意
    enable :sessions

    set :port, ENV.fetch('PORT', 3000)
    set :bind, ENV.fetch('BIND', '0.0.0.0') # ローカルで実行するなら不要

    # Hot reloadを有効化
    register Sinatra::Reloader
  end

  def authorization_url
    uri = URI.parse('https://api.notion.com/v1/oauth/authorize')
    uri.query = URI.encode_www_form(
      client_id: CLIENT_ID,
      redirect_uri: REDIRECT_URI,
      response_type: 'code',
      scope: 'all'
    )
    uri.to_s
  end
end

Application.run!

ログイン画面を実装する

ログイン画面を実装します。
とはいっても、Authorization URLするだけなので、/へアクセスされた場合に/loginへリダイレクトするのも実装しておきます。

class Application < Sinatra::Base
  ...

  get '/' do
    redirect '/login' if session[:access_token].nil? # セッションにアクセストークンがない場合は/loginへリダイレクト

    'TBD'
  end

  get '/login' do
    "<a href=\"#{authorization_url}\">Login with Notion</a>" # Authorization URLのリンクを表示
  end
end

この状態でアプリケーションを ruby app.rb で起動し、localhost:3000へアクセスすると、/loginへリダイレクトされます。

ログアウトを実装する

Notionからのコールバックを実装する前に先にログアウト処理を実装しておきます。
今回はセッションをクリアするだけでOKです。

class Application < Sinatra::Base
  ...

  # POSTの方がよいがGETのが動作検証が楽なのでGET
  get '/logout' do
    session.clear
    redirect '/'
  end
end

コールバックを実装する

Notionからのコールバックを実装します。 アクセストークンを発行したいので Create a token に従います。

class Application < Sinatra::Base
  ...

  # FYI: https://developers.notion.com/reference/create-a-token
  get '/callback' do
    code = params[:code]
    uri = URI.parse('https://api.notion.com/v1/oauth/token')
    bearer_token = Base64.strict_encode64("#{CLIENT_ID}:#{CLIENT_SECRET}") # Client ID,Secetを結合してBase64でエンコード
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = true
    response = http.post(
      uri.path,
      # Payload
      URI.encode_www_form(
        grant_type: 'authorization_code',
        redirect_uri: REDIRECT_URI,
        code: code
      ),
      # Headers
      {
        'Authorization' => "Basic #{bearer_token}",
        'Content-Type' => 'application/x-www-form-urlencoded;charset=UTF-8',
        'Notion-Version' => '2022-06-28',
      }
    )

    # 各種情報をセッションに保存
    session[:access_token] = JSON.parse(response.body)['access_token'] # アクセストークン
    session[:user_id] = JSON.parse(response.body)['owner']['id'] # ユーザID

    redirect '/'
  end
end

これでログイン処理の実装は終わりです。
後はユーザ名を表示すれば検証は終わりです。

ユーザ名を表示する

/でユーザ名を表示するようにします。
ユーザ情報を取得するには、Retrieve a userに従ってAPIリクエストします。

class Application < Sinatra::Base
  ...

  get '/' do
    redirect '/login' if session[:access_token].nil?

    uri = URI.parse("https://api.notion.com/v1/users/#{session[:user_id]}")
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = true
    response = http.get(
      uri.path,
      # Headers
      {
        'Authorization' => "Bearer #{session[:access_token]}",
        'Notion-Version' => '2022-06-28',
      }
    )
    results = JSON.parse(response.body)['results']
    user = results.first
    <<~HTML
      Hello, <b>#{user['name']}</b>!
      <br />
      <a href="/logout">Logout</a>
    HTML
  end

  ...
end

ここまで実装すれば一連の流れを画面上で操作でき、動作検証ができることが分かりました。

おわりに

Login with Notionを使ったサンプルをあまり見かけないので放流だけしておきました。
検証目的での実装なのでセキュリティは非常に脆弱なコードなので、間違ってもそのまま流用しないでください。