Table of Contents
はじめに
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以外にも対応しているサービスがあります。
- Spotify
- GitHub
- Discord
GitHubでOAuthアプリを登録する
上記URLへアクセスして、OAuthアプリを作成します。
作成時にClient IDとClient 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_ID
と GITHUB_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>
</>
)
}
ホーム画面
ログイン後に遷移するホーム画面です。
ログインユーザーのみアクセスできる画面になります。
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>
</>
)
}
これで完成です。無事にログイン、ログアウトできれば成功です。
おわりに
ざっくりGitHubログインの実装手順を紹介しましたが、意外と簡単にできてしまいました。
サードパーティーのパッケージを一切使わずにDB処理から画面表示まで含めたソーシャルログインの実装が簡単にできるのはAdonisJSの強みかもしれませんね。
サポートされているサービス以外にもカスタムドライバを作れば他のサービスにも対応できるようですので、実運用でも使えそうです。