Posted at

[Scala]SangriaでUpdateCtxを使ってGraphQLの認証を実装する

More than 1 year has passed since last update.

Scala用GraphQLフレームワークのsangriaUpdateCtxを使って認証処理を実装する。

使い方に注意点がいくつかあるが、まずは普通に動かすための方法について。


UpdateCtxを使って実装する

認証処理に関するドキュメントはLearn SangriaAuthentication and Authorisationセクションにあり、「Resolve-Based Auth」として紹介されているようにUpdateCtxを使って実装してみる。

その前に、まずはsangriaを動かすための前提となるCtxおよび共通して使用する型を定義する。

色々実装が雑なのはサンプルのためなので目を瞑る。

コード全体像はGithubにpushしてある。

// ユーザー

case class User(id: Long, name: String, email: String, password: String) {
def updateName(newName: String) = copy(name = newName)
}
trait UserDao { // databaseへの接続.実装は省略
def findAll: Seq[User]
def login(email: String, password: String): Try[Token]
def findByToken(token: Token): Option[User]
def update(newUser: User): Unit
}
// 認証トークン
case class Token(value: String)

// Ctxとして使用
case class GraphQLContext(userOpt: Option[User] = None) {
val userDao: UserDao = ???
// ログイン処理してtoken発行
def loggedIn(user: User): GraphQLContext = copy(userOpt = Some(user))
// tokenからuserを見付けて認証
def authenticate(token: Token): GraphQLContext = copy(userOpt = userDao.findByToken(token))
}

GraphQLContext.userDao.loginを使って認証するMutationを実装していく。

まずはloginしてCtxUpdateCtxで更新するFieldを実装する。

// 入力

val emailArg = Argument("email", StringType, "email of user")
val passwordArg = Argument("password", StringType, "password of user")

// 出力
val authenticateType: ObjectType[Unit, Token] = derive.deriveObjectType[Unit, Token]()

// loginしてUpdateCtxするField
val authenticateField = fields[GraphQLContext, Unit](
Field(
"login",
authenticateType,
arguments = emailArg :: passwordArg :: Nil,
resolve = { ctx =>
ctx.withArgs(emailArg, passwordArg) { (email, password) =>
// login処理を実行
UpdateCtx(ctx.ctx.userDao.login(email, password)) { token: Token =>
// loginが成功(Successなら)したらctxを更新
ctx.ctx.authenticate(token)
}
}
}
)
)

続いてログイン中のUserの情報を取得する、Userを更新するFieldを用意する。

// 入力

val nameArg = Argument("name", StringType, "name of user")
// 出力
val userType: ObjectType[Unit, User] = derive.deriveObjectType[Unit, User]()

val userField = fields[GraphQLContext, Unit](
Field(
"get", // login中のuserを取得する
OptionType(userType),
arguments = Nil,
resolve = ctx => ctx.ctx.userOpt
),
Field(
"update", // nameを更新する
userType,
arguments = nameArg :: Nil,
resolve = { ctx =>
ctx.withArgs(nameArg) { name =>
val user = ctx.ctx.userOpt.get // 雑にOption.get
val newUser = user.updateName(name)
UserDao.update(newUser)
newUser
}
}
)
)

これらのFieldをMutationとして公開する。

val mutation = ObjectType(

"Mutation",
fields[GraphQLContext, Unit](
// loginするのとuserのget/updateをMutationとして公開
authenticateField ++ userField : _*
)
)

このmutationを使ってSchemaを作ってGraphQLのQueryを実行してみる

mutation Login {

login(email: "hoge@example.com", password: "password") {
value
}
get {
id
name
}
update(name: "updated!") {
id
name
}
}

結果は以下のように得ることが出来る。

{

"data": {
"login": {
"value": "token-1"
},
"get": {
"id": 1,
"name": "user-1"
},
"update": {
"id": 1,
"name": "updated"
}
}
}

無事にloginして、UpdateCtxによってCtxに与えられたUserの情報をgetupdateで参照することが出来た。


UpdateCtxの注意点

便利なUpdateCtxだが注意点がいくつかある。


クエリ内でUpdateCtxを記述する位置

UpdateCtxがあるFieldを考慮して実行順序をいい感じにしてくれるようにはなっていない。

単純に書いた順番、先程の例だとlogin, get, updateと上から順に実行されている。


なので、順番を入れ替えるとダメになる。

mutation LoginAndUpdate {

get {
id
name
}
login(email: "user-1@example.com", password: "password") {
value
}
update(name: "updated") {
id
name
}
}

結果はgetだけlogin中のuserがCtxから取得できない。


loginしてupdateは無事に成功する。

{

"data": {
"get": null,
"login": {
"value": "1"
},
"update": {
"id": 1,
"name": "updated"
}
}
}

つまりUpdateCtxはそれ以降のCtxを更新するものなので、使用する場合はクライアント側で順番に注意して記述する必要がある。


UpdateCtxはqueryでは効かない

上ではmutationのサンプルを実装したが、queryでも試してみる。

login中のuser情報を返すだけのqueryを実装する。

val userQuery = ObjectType(

"Query",
fields[GraphQLContext, Unit](
Field(
"get", // mutationのgetと同じ
OptionType(userType),
arguments = Nil,
resolve = ctx => ctx.ctx.userOpt
)
)
)

これでSchemaを作成し、実行してみる。


投げるクエリは以下。

先程のmutation LoginAndUpdateからqueryに変化しているだけ。

query MyQuery {

login(email: "user-1@example.com", password: "password") {
value
}
get {
id
name
}
}

実行してみると以下の結果が得られる。

{

"data": {
"login": {
"value": "token-1"
},
"get": null
}
}

つまり、queryではUpdateCtxが効いていない。

UpdateCtxはmutationでしか有効でないので、queryでも認証したければUpdateCtx以外で行う必要がある。


ネストしたパスではUpdateCtxが効かない

GraphQLだとトップレベルにクエリを並べることが多いとは思うが、入れ子にすることも出来る。

先程実装したmutationにprefixを付けてみる。

val mutation =

ObjectType(
"Mutation",
fields[GraphQLContext, Unit](
Field("prefix",
ObjectType("prefix",
fields[GraphQLContext, Unit](authenticateFields ++ userMutation: _*)),
resolve = _ => ()
)
)
)

これに対して先程と同じようにloginしてgetしてupdateするクエリを投げてみる。

prefixというパスの中に全部入る形となる。

mutation LoginAndUpdate {

prefix {
login(email: "user-1@example.com", password: "password") {
value
}

get {
id
name
}

update(name: "updated") {
id
name
}
}
}

結果はこんな感じ。

{

"data": null,
"errors": [
{
"message": "Internal server error",
"path": [
"prefix",
"update"
],
"locations": [
{
"line": 41,
"column": 5
}
]
}
]
}

updateの内部でOption.getしているところでNoSuchElementExceptionがthrowされてしまっている。

prefix > loginだけにしてみると、期待通り実行されている。

mutation Login {

prefix {
login(email: "user-1@example.com", password: "password") {
value
}
}
}

結果は以下の通り。

{

"data": {
"prefix": {
"login": {
"value": "token-1"
}
}
}
}

UpdateCtxを使うFieldはトップレベルに配置すること。

つまり、先ほどprefixに入れ込んだ実装を少し変えてauthenticateFieldsだけ外に出すと期待通り動くようになる。

val mutation =

ObjectType("Mutation",
// ここでauthenticateFieldsはトップレベルに配置する
authenticateFields ++ fields[GraphQLContext, Unit](
Field("prefix", // それ以外はネストしていても問題ない
ObjectType("prefix", fields[GraphQLContext, Unit](userMutation: _*)),
resolve = _ => ())
))

これに対してloginしてgetしてupdateするクエリを投げてみる。

mutation LoginAndUpdate {

login(email: "user-1@example.com", password: "password") {
value
}
prefix {
get {
id
name
}
update(name: "updated") {
id
name
}
}
}

結果は期待通り、無事にUpdateCtxが機能している。

{

"data": {
"login": {
"value": "token-1"
},
"prefix": {
"get": {
"id": 1,
"name": "user-1"
},
"update": {
"id": 1,
"name": "updated"
}
}
}
}


所感

UpdateCtxの使い方には癖があるが、GraphQLのクエリだけで認証を完結させられるので便利。

しかし、Queryで利用できないのは厳しい。

実際のプロダクト開発だとHttp-Headerに認証Tokenを入れてGraphQLとは別のレイヤーで認証することになりそう。