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
}

next-auth/packages/next-auth/src/providers/boxyhq-saml.ts at 8288ae5be80cb5eefb220850fc6150521dee6c46 · nextauthjs/next-auth · GitHub

本来この 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,
  })
}

next-auth/packages/next-auth/src/core/lib/oauth/callback.ts at e6590ffc20233e12216d0e95a1c1a1e83b6b9131 · nextauthjs/next-auth · GitHub

ここでは 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 にセットしつつ、可能な限り idemail といった値に変換するということをやっています。

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 側で属性マッピングの設定を行ったかの確認は基本的に行った方が良いのでそこは注意が必要です。