next-auth + BoxyHQSAMLProvider で属性マッピングしてないのに id や email が取れることがある
というお話。
なおどちらも既に名前が変わっており、 next-auth は Auth.js だし、 BoxyHQ SAML は Ory Polis なのですが、自分が使ってるのはこの名前になる前のバージョンなので、一旦この記事ではタイトルどおりの呼び方とさせてください。
もう少しタイトルの現象について具体的に説明します。next-auth では signin 時に signin callback 内で profile が取得できます。この profile に入っている値は利用してるプロバイダによって異なるのですが、BoxyHQSAMLProvider では以下のようになっています。
export interface BoxyHQSAMLProfile extends Record<string, any> {
id: string
email: string
firstName?: string
lastName?: string
}
本来この id や email は IdP による属性マッピングによって変換されて渡されてきます。そのためセットアップリンクを使用した公式手順*1ではこの属性マッピング設定を行うように指定されているのですが、これを行わなくても自動で変換されるケースがあります。
まず先程 profile では上記のようなデータが渡ってくると言いましたが、厳密にはこれだけではありません。型定義に存在しないだけで様々な値が返ってきます。実際に profile の中身を見てみるとこのようなデータが返ってきます。
{
"raw": {
"id": "xxxxxxxxxx",
"firstName": "太郎",
"lastName": "田中",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": "hoge@example.com",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": "hoge@example.com"
},
"id": "xxxxxxxxxx",
"email": "hoge@example.com",
"firstName": "太郎",
"lastName": "田中",
"idHash": "xxxxxxxxxx",
"requested": {
...
}
}
なおここでの IdP による属性マッピングの設定は id, firstName, lastName は正しくマッピング設定済み、 email は特に行っていない状態です。しかし実際には email というキーに正しく email が返ってきています…。つまりどこかしらで自動変換がなされているということになりますね。
さてここで注目していただきたいのが raw というオブジェクト構造です。これはどこから返ってきているのでしょう?
next-auth v4 における profile を取得している場所がここになります。
if (provider.userinfo?.request) {
profile = await provider.userinfo.request({
provider,
tokens,
client,
})
} else if (provider.idToken) {
profile = tokens.claims()
} else {
profile = await client.userinfo(tokens, {
params: provider.userinfo?.params,
})
}
ここでは provider に設定された userinfo エンドポイントに従ってリクエストを送るような実装になっています。BoxyHQSAMLProvider (OAuth 版)だと ${options.issuer}/api/oauth/userinfo となっています。issuer が BoxyHQ SAML Admin Portal の場合、アクセス先はここです。
polis/npm/src/controller/oauth.ts at fb78baebca9dab3bbadc044bd3232a7166ad25f7 · ory/polis · GitHub
ここの openapi 定義を見ると確かに raw というデータが返ってますね。この raw がどこで埋められているのかを辿っていくと以下のコードにたどり着きます
const map = (claims: Record<attributes | schemas, unknown>) => {
arrayMapping.forEach((m) => {
if (claims[m.attribute]) {
claims[m.attribute] = [].concat(claims[m.attribute] as any);
} else if (claims[m.schema]) {
claims[m.schema] = [].concat(claims[m.schema] as any);
}
});
const profile = {
raw: claims,
};
mapping.forEach((m) => {
if (claims[m.attribute]) {
profile[m.attribute] = claims[m.attribute];
} else if (claims[m.schema]) {
profile[m.attribute] = claims[m.schema];
}
});
return profile;
};
polis/npm/src/saml/claims.ts at 1177ce101197a6e1aa32cad89ca6fba3163e1650 · ory/polis · GitHub
これを呼び出しているのは extractSAMLResponseAttributes という関数です。
// Validate the SAMLResponse and extract the user profile
export const extractSAMLResponseAttributes = async (
decodedResponse: string,
validateOpts: ValidateOption
) => {
const attributes = await saml.validate(decodedResponse, validateOpts);
if (attributes && attributes.claims) {
// We map claims to our attributes id, email, firstName, lastName where possible. We also map original claims to raw
attributes.claims = claims.map(attributes.claims);
// Some providers don't return the id in the assertion, we set it to a sha256 hash of the email
if (!attributes.claims.id && attributes.claims.email) {
attributes.claims.id = crypto.createHash('sha256').update(attributes.claims.email).digest('hex');
}
}
// we'll send a ripemd160 hash of the id, this can be used in the case of email missing it can be used as the local part
attributes.claims.idHash = dbutils.keyDigest(attributes.claims.id);
return attributes;
};
polis/npm/src/saml/lib.ts at 74becab9f286078aa66c5c3bcbaa83917e719f2c · ory/polis · GitHub
じつはこの2つのファイルが答えになるのですが、要は SAMLResponse で attributes.claims として渡されてきたやつを raw にセットしつつ、可能な限り id や email といった値に変換するということをやっています。
We map claims to our attributes id, email, firstName, lastName where possible. We also map original claims to raw
そのマッピング設定が saml/claims.ts の上の方に書いてあるこの部分ですね。
const rolesSchema = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/role';
const groupsAttribute = 'groups';
const groupsSchema = 'http://schemas.xmlsoap.org/claims/Group';
const arrayMapping = [
{
attribute: rolesAttribute,
schema: rolesSchema,
},
{
attribute: groupsAttribute,
schema: groupsSchema,
},
];
const mapping = [
{
attribute: 'id',
schema: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier',
},
{
attribute: 'email',
schema: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
},
{
attribute: 'firstName',
schema: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname',
},
{
attribute: 'lastName',
schema: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname',
},
...arrayMapping,
];
polis/npm/src/saml/claims.ts at 1177ce101197a6e1aa32cad89ca6fba3163e1650 · ory/polis · GitHub
ここで最初の profile 例の raw の中身を思い出していただきたいのですが
{
"raw": {
"id": "xxxxxxxxxx",
"firstName": "太郎",
"lastName": "田中",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": "hoge@example.com",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": "hoge@example.com"
},
...
}
この様になっていました。これは IdP 側で id, firstName, lastName は属性マッピングの設定が行われているため、そのキーどおりに表示されていますが、 email 等は設定していないので http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress というキーになっているわけですね。先程のマッピング設定と照らし合わせるとこれは email として自動変換される対象になります。これが属性マッピング設定を行っていないにも関わらず email が取れる現象につながっているわけですね。
とはいえ全てのプロバイダでこのような形で降ってくるわけではなく、あくまでこの形式で降ってきてくれたら自動変換するよ〜というだけであり、ちゃんと IdP 側で属性マッピングの設定を行ったかの確認は基本的に行った方が良いのでそこは注意が必要です。