この記事は K3 Advent Calendar 13 日目の記事です。
え?予定に書いてあった記事名とぜんぜん違うって?大きな勘違いをしていてやろうとしていたことが不可能になったんです…許して…
以前アプリケーション班の進捗置き場に置いた進捗ですが、せっかくなのでこれを用いてOIDCプロバイダって何?というのを記事にしたいと思います。
OIDCプロバイダとは?
そもそもOIDCプロバイダというのは、OIDC(OpenID Connect) というプロトコルを実装したサーバーアプリケーションのことです。このプロトコルはユーザーの「身分証明」を行うプロトコルです。
といってもよくわからないので、まず現実世界のケースで身分証明とはなにかを考えましょう。
例えば僕が市役所に行ってなにか手続きをしたいとしましょう。このとき身分証明をする必要がありますが、自分で作った名刺なんか出しても「は?」って言われるだけですよね。
自己証明は意味がない
なぜ市役所職員は僕が丹精込めて作った名刺を突っぱねたのか?それは daima3629 であると自分で勝手に名乗っているからです。勝手に名乗るだけなら誰でもできますよね。それでは全く持って証明ができていない、ということです。
権威ある他人に証明してもらう
どうすれば職員に僕が僕であると認めてもらえるのでしょうか。自分で証明するのがだめなら、他人に証明してもらえば良いんです。
でも、他人なら誰でもいいわけじゃありません。そこらへんの人に証明してもらったってだめです。
例えば、市役所で使える身分証明書であるマイナンバーカードは国が発行しています。お国に自分のことを証明してもらうわけですね。
OIDC における身分証明
OIDC の話に戻りましょう。上の例では国が僕の身分を証明しましたが、OIDC プロトコルでは OIDC プロバイダが僕の身分を証明します。
身分証明を必要とするアプリケーションは、OIDC プロバイダに対して ID トークンというものを要求します。この ID トークンが現実世界のケースで言うマイナンバーカードに当たります。
ID トークンにはユーザーのいろいろな情報と、それに対する電子署名が含まれています。この電子署名によって、OIDC プロバイダが正規に ID トークンを発行したということを保証しています。
実装を見てみよう
では以上の話を踏まえて OIDC プロバイダが実際にどういう動きをしているのか、コードを見ながら確認してみましょう。
僕が作った OIDC プロバイダは discord-oidc-worker をほぼ丸パクリして使っていますので、これを題材とします。
OIDC は実際には ID トークンを発行するまでに割と長いフローを踏みますが、今回は端折って実際に ID トークンを発行する部分である/token
エンドポイントだけかいつまんで見てみます。
ID トークンを発行するエンドポイント全体のコード
app.post('/token', async (c) => {
const body = await c.req.parseBody()
const code = body['code']
const params = new URLSearchParams({
'client_id': config.clientId,
'client_secret': config.clientSecret,
'redirect_uri': config.redirectURL,
'code': code,
'grant_type': 'authorization_code',
'scope': 'identify email'
}).toString()
const r = await fetch('https://discord.com/api/v10/oauth2/token', {
method: 'POST',
body: params,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}).then(res => res.json())
if (r === null) return new Response("Bad request.", { status: 400 })
const userInfo = await fetch('https://discord.com/api/v10/users/@me', {
headers: {
'Authorization': 'Bearer ' + r['access_token']
}
}).then(res => res.json())
if (!userInfo['verified']) return c.text('Bad request.', 400)
let servers = []
const serverResp = await fetch('https://discord.com/api/v10/users/@me/guilds', {
headers: {
'Authorization': 'Bearer ' + r['access_token']
}
})
if (serverResp.status === 200) {
const serverJson = await serverResp.json()
servers = serverJson.map(item => {
return item['id']
})
}
let roleClaims = {}
if (c.env.DISCORD_TOKEN && 'serversToCheckRolesFor' in config) {
await Promise.all(config.serversToCheckRolesFor.map(async guildId => {
if (servers.includes(guildId)) {
let memberPromise = fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${userInfo['id']}`, {
headers: {
'Authorization': 'Bot ' + c.env.DISCORD_TOKEN
}
})
// i had issues doing this any other way?
const memberResp = await memberPromise
const memberJson = await memberResp.json()
roleClaims[`roles:${guildId}`] = memberJson.roles
}
}
))
}
let preferred_username = userInfo['username']
if (userInfo['discriminator'] && userInfo['discriminator'] !== '0'){
preferred_username += `#${userInfo['discriminator']}`
}
let displayName = userInfo['global_name'] ?? userInfo['username']
const idToken = await new jose.SignJWT({
iss: 'https://cloudflare.com',
aud: config.clientId,
preferred_username,
...userInfo,
...roleClaims,
email: userInfo['email'],
global_name: userInfo['global_name'],
name: displayName,
guilds: servers
})
.setProtectedHeader({ alg: 'RS256' })
.setExpirationTime('1h')
.setAudience(config.clientId)
.sign((await loadOrGenerateKeyPair(c.env.KV)).privateKey)
return c.json({
...r,
scope: 'identify email',
id_token: idToken
})
})
Discord API からの情報取得
const userInfo = await fetch('https://discord.com/api/v10/users/@me', {
headers: {
'Authorization': 'Bearer ' + r['access_token']
}
}).then(res => res.json())
const serverResp = await fetch('https://discord.com/api/v10/users/@me/guilds', {
headers: {
'Authorization': 'Bearer ' + r['access_token']
}
})
ここらへんでまず Discord API にリクエストして、ユーザー情報を取ってきています。今回は Discord ユーザーの情報を使って ID トークンを作るので、その材料を手に入れる必要があるわけですね。
ID トークンの生成
const idToken = await new jose.SignJWT({
iss: 'https://cloudflare.com',
aud: config.clientId,
preferred_username,
...userInfo,
...roleClaims,
email: userInfo['email'],
global_name: userInfo['global_name'],
name: displayName,
guilds: servers
})
.setProtectedHeader({ alg: 'RS256' })
.setExpirationTime('1h')
.setAudience(config.clientId)
.sign((await loadOrGenerateKeyPair(c.env.KV)).privateKey)
ここで ID トークンを生成しています。
勘のいい人はこの時点で気づくかもしれませんが、ID トークンの実態はただの JSON です。正確には メタデータ(JSON).メインデータ(JSON).署名
という文字列を base64 エンコードしたものになります。
SignJWT
関数内で指定している各パラメータは OIDC Claim と言い、ユーザーデータはもちろん、ID トークン自体の説明も含んでいます。
細かく見ていくと、
iss: 'https://cloudflare.com',
aud: config.clientId,
iss
は誰が発行したか、aud
は誰に対して発行したかの識別子です。
これは Cloudflare が発行したということになりますが、実際はぜんぜん違うのでこれは良くない実装ですね(執筆中に気が付きました)
ちなみに、これら2つははじめから定義されているフィールドです。他にどんなものが定義されているのか知りたい場合は RFC を読んでみてください。英語読めないよ;;
preferred_username,
...userInfo,
...roleClaims,
email: userInfo['email'],
global_name: userInfo['global_name'],
name: displayName,
guilds: servers
ここらへんは全部独自定義のフィールドです。Discord API から取ってきた情報を少し加工したものを丸ごと突っ込んでいるイメージですね。
電子署名
.sign((await loadOrGenerateKeyPair(c.env.KV)).privateKey)
ここで作成した ID トークンに対して電子署名を行っています。この署名は別のエンドポイントでアクセスできる公開鍵によって検証できます。
まとめ
- OIDC(OpenID Connect) は身分証明をするプロトコル
- 電子署名を用いて正当性を担保する
Discord API 要素薄くね…?と思ったそこのあなた、埋めますよ。