AdonisJSでGitHubログイン機能を実装する

Sep 19, 2024

はじめに

AdonisJSで@adonisjs/allyを利用してGitHubログインを実装してみます。

プロジェクト作成

npm init adonisjs@latest github-login-app -- --db=postgres --kit=inertia --auth-guard

今回はInertiaスターターキット、DBはPostgres、認証はセッションで始めます。

@adonisjs/ally インストール

@adonisjs/allyパッケージを使用してGitHubログインを実現できます。

node ace add @adonisjs/ally --providers=github

GitHub以外にも対応しているサービスがあります。

  • Twitter
  • Facebook
  • Spotify
  • Google
  • GitHub
  • Discord
  • LinkedIn

GitHubでOAuthアプリを登録する

上記URLへアクセスして、OAuthアプリを作成します。

github-oauth

作成時にClient IDClient secretsをメモしておきます。

一旦ローカル環境で動かすため、Homepage URLとAuthorization callback URLは以下に設定しました。

  • Homepage URL: http://localhost:3333/
  • Authorization callback URL: http://localhost:3333/github/callback

設定

config/ally.tsファイルで先ほど登録したOAuthアプリの設定を記述します。

import env from '#start/env'
import { defineConfig, services } from '@adonisjs/ally'

const allyConfig = defineConfig({
  github: services.github({
    clientId: env.get('GITHUB_CLIENT_ID'),
    clientSecret: env.get('GITHUB_CLIENT_SECRET'),
    callbackUrl: 'http://localhost:3333/github/callback',
    scopes: ['read:user'],
  }),
})

export default allyConfig

declare module '@adonisjs/ally/types' {
  interface SocialProviders extends InferSocialProviders<typeof allyConfig> {}
}

GITHUB_CLIENT_IDGITHUB_CLIENT_SECRET は.envファイルにメモした値をそれぞれ設定してください。

リダイレクトとコールバック

次にGitHubログインへ飛ばすためのリダイレクト処理とログイン成功時の戻り先のコールバック処理を実装します。

まずはコントローラを生成します。

node ace make:controller social

そしてリダイレクトとコールバック処理、さらにログアウト処理を追加します。

import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'

export default class SocialsController {
  githubRedirect({ ally }: HttpContext) {
    return ally.use('github').redirect((req) => {
      req.scopes(['user'])
    })
  }

  async githubCallback({ ally, response, session, auth }: HttpContext) {
    const gh = ally.use('github')

    /**
     * User has denied access by canceling
     * the login flow
     */
    if (gh.accessDenied()) {
      session.flash('error', 'You denied access to your GitHub account')
      return response.redirect().toRoute('login')
    }

    /**
     * OAuth state verification failed. This happens when the
     * CSRF cookie gets expired.
     */
    if (gh.stateMisMatch()) {
      session.flash('error', 'Request expired. Please try again')
      return response.redirect().toRoute('login')
    }

    /**
     * GitHub responded with some error
     */
    if (gh.hasError()) {
      session.flash('error', 'Unable to login using GitHub. Error details: ' + gh.getError())
      return response.redirect().toRoute('login')
    }

    /**
     * Access user info
     */
    const ghUser = await gh.user()

    const user = await User.firstOrCreate(
      {
        email: ghUser.email,
      },
      {
        fullName: ghUser.name,
        email: ghUser.email,
        avatarUrl: ghUser.avatarUrl,
      }
    )

    await auth.use('web').login(user)
    session.flash('success', 'Logged in successfully')
    return response.redirect().toRoute('home')
  }

  async login({ inertia }: HttpContext) {
    return inertia.render('login')
  }

  async logout({ auth, response, session }: HttpContext) {
    await auth.use('web').logout()
    session.flash('success', 'Logged out successfully')
    return response.redirect().toRoute('login')
  }
}

@adonisjs/ally を導入すると、HttpContext内にAllyServiceのインスタンスが加わるので、それを利用してリダイレクトやコールバック後のユーザー情報取得を行なっています。

ルーティング

作成したコントローラのアクションを各ルートに割り当てていきます。

start/routes.ts:


import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
const HomeController = () => import('#controllers/home_controller')
const SocialsController = () => import('#controllers/socials_controller')

router.get('/', [HomeController, 'index']).as('home').use(middleware.auth())
router.get('/login', [SocialsController, 'login']).as('login').use(middleware.guest())
router.delete('/logout', [SocialsController, 'logout']).as('logout').use(middleware.auth())
router
  .group(() => {
    router.get('redirect', [SocialsController, 'githubRedirect']).as('redirect')
    router.get('callback', [SocialsController, 'githubCallback']).as('callback')
  })
  .prefix('github')
  .as('github')

これで以下のルートが設定できました。

  • GET / (home)
  • GET /login (login)
  • GET /github/redirect (github.redirect)
  • GET /github/callback (github.callback)
  • DELETE /logout (logout)

あとはHomeControllerを作っていなかったので作っておきます。

node ace make:controller home

app/controllers/home_controller.ts:

import type { HttpContext } from '@adonisjs/core/http'

export default class HomeController {
  async index({ inertia }: HttpContext) {
    return inertia.render('home')
  }
}

Userテーブル作成

Starter KitデフォルトのUserテーブルのマイグレーションではパスワードが必須になってしまっています。

OAuth認証ではパスワードはこちらのアプリで管理する必要がないため、Nullableに設定が必要です。

database/migrations/xxxxxx_create_users_table.tsのようなファイルが作られていると思うので、それを編集します。

import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
  protected tableName = 'users'

  async up() {
    this.schema.createTable(this.tableName, (table) => {
      table.increments('id').notNullable()
      table.string('full_name').nullable()
      table.string('email', 254).notNullable().unique()
      table.string('password').nullable() // <- 変更
      table.string('avatar_url').nullable() // <- 追加

      table.timestamp('created_at').notNullable()
      table.timestamp('updated_at').nullable()
    })
  }

  async down() {
    this.schema.dropTable(this.tableName)
  }
}

GitHubではプロフィール写真のURLも取れるのでついでにavatar_urlカラムを追加しています。

マイグレーションを実行します。

node ace migration:run

Userモデル修正

カラムを変更、追加したのでモデル側も修正します。

app/models/user.ts:

import { DateTime } from 'luxon'
import hash from '@adonisjs/core/services/hash'
import { compose } from '@adonisjs/core/helpers'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'

const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
  uids: ['email'],
  passwordColumnName: 'password',
})

export default class User extends compose(BaseModel, AuthFinder) {
  @column({ isPrimary: true })
  declare id: number

  @column()
  declare fullName: string | null

  @column()
  declare email: string

  @column({ serializeAs: null })
  declare password: string | null

  @column()
  declare avatarUrl: string | null

  @column.dateTime({ autoCreate: true })
  declare createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  declare updatedAt: DateTime | null
}

ユーザー情報をビューに渡す

InertiaのsharedDataを使用して、ログインユーザーの情報を各画面で取得できるようにします。 また、falshメッセージもついでに渡しています。

import User from '#models/user'
import { defineConfig } from '@adonisjs/inertia'
import type { InferSharedProps } from '@adonisjs/inertia/types'

class UserDto {
  constructor(private user: User) {}

  toJson() {
    return {
      id: this.user.id,
      fullName: this.user.fullName,
      email: this.user.email,
      avatarUrl: this.user.avatarUrl,
    }
  }
}

const inertiaConfig = defineConfig({
  /**
   * Path to the Edge view that will be used as the root view for Inertia responses
   */
  rootView: 'inertia_layout',

  /**
   * Data that should be shared with all rendered pages
   */
  sharedData: {
    user: (ctx) => (ctx.auth.user ? new UserDto(ctx.auth.user).toJson() : null),
    error: (ctx) => ctx.session?.flashMessages.get('error') as string,
    success: (ctx) => ctx.session?.flashMessages.get('success') as string,
  },

  /**
   * Options for the server-side rendering
   */
  ssr: {
    enabled: true,
    entrypoint: 'inertia/app/ssr.tsx',
  },
})

ここで単純にctx.auth.userをreturnせずUserDtoクラスを挟んでいる理由としては、モデルクラスをそのまま返してもフロントエンド側に型が伝わらないからです。 UserDtoクラスを利用してObjectに変換してあげる処理が必要になります。 本来は別ファイルに書いてあげるべきですが、簡略化のためinertia.ts内に書いています。

ログイン画面

最後に画面を用意する必要がありますが、まずはログイン画面です。

Note

見た目を整えるため、shadcn/uiとtailwindcssを事前に導入しています。

inertia/pages/login.tsx:

import SocialsController from '#controllers/socials_controller'
import { AdonisJS } from '@/components/icons/adonisjs'
import { GitHub } from '@/components/icons/github'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { InferPageProps } from '@adonisjs/inertia/types'
import { Head } from '@inertiajs/react'

export default function Login(props: InferPageProps<SocialsController, 'login'>) {
  const { success } = props
  return (
    <>
      <Head title="Login" />
      <div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8">
        <div className="mx-auto max-w-md space-y-12">
          {success && (
            <Alert>
              <AlertTitle>Success</AlertTitle>
              <AlertDescription>{success}</AlertDescription>
            </Alert>
          )}
          {error && (
            <Alert variant="destructive">
              <AlertTitle>Error</AlertTitle>
              <AlertDescription>{error}</AlertDescription>
            </Alert>
          )}
          <div className="flex items-center justify-center flex-col gap-4">
            <AdonisJS className="h-12 w-12" />
            <h2 className="text-2xl text-center">AdonisJS Tutorial</h2>
          </div>
          <div className="space-y-4">
            <div className="grid gap-2">
              <Button variant="outline" className="w-full" asChild>
                <a href="/github/redirect" className="no-underline text-primary">
                  <GitHub className="mr-2 h-4 w-4" />
                  Sign in with GitHub
                </a>
              </Button>
            </div>
          </div>
        </div>
      </div>
    </>
  )
}

github-login

ホーム画面

ログイン後に遷移するホーム画面です。

ログインユーザーのみアクセスできる画面になります。

inertia/pages/home.tsx:

import HomeController from '#controllers/home_controller'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { InferPageProps } from '@adonisjs/inertia/types'
import { Head, router } from '@inertiajs/react'

export default function Home(props: InferPageProps<HomeController, 'index'>) {
  const { user, success, error } = props
  return (
    <>
      <Head title="Homepage" />
      <div className="w-full h-full gap-6 p-6 flex items-center justify-center flex-col">
        {success && (
          <Alert>
            <AlertTitle>Success</AlertTitle>
            <AlertDescription>{success}</AlertDescription>
          </Alert>
        )}
        {error && (
          <Alert variant="destructive">
            <AlertTitle>Error</AlertTitle>
            <AlertDescription>{error}</AlertDescription>
          </Alert>
        )}
        {user?.avatarUrl && (
          <img
            className="w-12 h-12 rounded-full object-cover border-2 border-gray-200"
            src={user?.avatarUrl}
            alt={user?.fullName ?? 'avatar'}
          />
        )}
        <h2 className="text-2xl">Welcome {user?.fullName}!</h2>
        <Button
          onClick={() => {
            router.delete('/logout')
          }}
        >
          Logout
        </Button>
      </div>
    </>
  )
}

home

これで完成です。無事にログイン、ログアウトできれば成功です。

おわりに

ざっくりGitHubログインの実装手順を紹介しましたが、意外と簡単にできてしまいました。

サードパーティーのパッケージを一切使わずにDB処理から画面表示まで含めたソーシャルログインの実装が簡単にできるのはAdonisJSの強みかもしれませんね。

サポートされているサービス以外にもカスタムドライバを作れば他のサービスにも対応できるようですので、実運用でも使えそうです。