3日坊主ITエンジニア > ASP.NET COREでOpenIdConnect(OIDC)を使う。ついでにテスト用のOIDCサーバを自作する > テスト用のOIDCサーバを自作する(ASP.NET COREのControllerで作る)
テスト用のOIDCサーバを自作する(ASP.NET COREのControllerで作る)

概要
テスト用のOIDCサーバをASP.NET CORE6 MVCのControllerで作ります。
本ページはプログラムがメインなので、OIDCの概要や仕組みなどはASP.NET COREでOpenIdConnect(OIDC)を使う。ついでにテスト用のOIDCサーバを自作するをご参照ください。
また、OIDCでの各種チェックについてはIDトークンやアクセストークンでのなりすましの防止についてをご参照ください。
テスト用のOIDCサーバを作るのですが、まとめてWebサーバの処理も作ります。
つまり、1つのプロジェクトにテスト用のOIDCサーバとWebサーバを混ぜ込んで作ります。
Blazor Web Assemblyを使ってaccess_token用のプログラムを作る場合は、ブラウザサイド(Blazor WebAssembly)からの自作OIDCサーバの呼び出しをご参照ください。
プロジェクト構成
プロジェクトをVisual Studio2022の「ASP.NET Core Webアプリ(Model-View-Controller)」テンプレートで作成します。


Nugetは、以下のものをインストールします。
・Microsoft.AspNetCore.Authentication.OpenIdConnect
そうして作成されたプロジェクトに、テスト用のOIDCサーバソースと、その呼び出し処理を加えると、以下のプロジェクト構成になります。
赤枠は、OIDCサーバソースで、
青枠は、OIDCサーバ呼び出しのために修正したソースになります。

テスト用のOIDCサーバプログラム
ここからは、プログラムを貼り付けていきます。プログラムの説明はプログラム中のコメントを参照するか、本ページの親ページASP.NET COREでOpenIdConnect(OIDC)を使うをご参照ください。
AuthServerController.cs
テスト用OIDCサーバのWebAPI部分になります。これがメインの処理になります。
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Policy;
using System.Text;
using System.Text.Json.Serialization;
using TestWebSv.Models.AuthServer.ViewModel;
using TestWebSv.Models.AuthServer.JsonModel;
namespace TestWebSv.Server.Controllers
{
[Route("[controller]")]
[ApiController]
public class AuthServerController : Controller
{
private IConfiguration _Configuration;
// 発行者
private string _OidcIssure;
private string _OidcClientId;
private string _OidcAuthority;
private string _OidcServerAuthority;
private string _OidcTokenTime;
private string _OidcRefreshTime;
// RSA秘密鍵 pem方式(Base64)
// openssl genrsa -out private.pem 2048
private string _OidcRsaPrivateKeyPem = "MIIEowIBAAKCAQEAv+lk5Z6Ht7OMj/Wu3V3BB2IXRAKzNLjlZjMiOZp71KrgtjEUVf5YH55jdvmXeDqH8gOpGcr/AnFWzXZggmt+vNSLrnf2Yy+jD7esuHQWjovvmi2N5RXsMfiPyGbqvLCjnRd+gDtqH/mX/sKyd0A9giht9LkQGS2xzKGszXU9SdQqd+/MoGBRAQNUdK4zx6azdw3JRYALVHB7trF4ISmbVT877cHXdAOnuR26Ttgtp2mEQgGmhwCmZRCJFLUiY9RcMZmFsQQ2aqjTOCiDmFy1hFMCKkSAU9tH9nKYX+V/r4QxUX0izdEBnKzzndyfHZ+ZNnYDIrHqFObwQ75+FHj8+wIDAQABAoIBAFaSds2OojKgcHxQnD2IGZe3DD/F6AmUjwd3ca0Cn3HkU9JceYwBXMeGr9/v4ACAhusJ87KK/FahwkKVcBvlWhrc1tYxj5hW2PwyI1xiIfrT7ZZjjmsVZKN2OYZxxtqv5F7tRkeahdk+wu5N7iwVcqnQiymmgjiZgGeLV2SnyqoOne7TDRBsIxuuQQVGx1KbnyGFdZ8GwwszNCntRTbs7tBDmgkjC0yF0MFwnrWHa7JIMt2eNhBHPW7lutfcW59EKRAtFfZeulCh6bYyQlfr4CrkVm2VwHJ93/OtrneDsdnFiyAS485cR3NLHeEt5a1hzsK9gBw2Q8gQbfF6xcQJMMECgYEA/itA90WxnMZ/xBb5S160/6zA+H7x02ts/2l04It2BkeF/xx2on+ZvogAEx/XIdkhxu/LjGDczTKRx3RcmqkK0cIDlUokgKBXdLZim4KpppRjoLO4XzALYV8c4kQLyhZJstr0LbB46+gJgJy9AOKfUzU57LJ6e2xjlPot5Wuve+kCgYEAwUtS1tkhdaQ/fyfZKtTHA8qJk2UnXTJsi1MCM7m0gJuKQBEnj9hiCYyLfByPFKh1NSYBbCWtsBCgr22lAzZ6FM6Bn5leREqTXdAw3C98DfGGJJb3PkRrd719hmdW+oNPBpsg6OljZTNaHPbWnSDa1HX+Kso3gGgfIprrl/E4t0MCgYB9NJmANdjUvgPaeOa9dh37hQJaZ06BM34yI6TrqEevuDQOA9t9GRaHgT9oLFsx1WCKOz4uHNkoTCz78BpeJb+qrMRPGoL4ygPK/r18ldU0tgyN8Xp1iZlRkiUMYTDdkDUl4i4/A5vNujRUIuIIOZr6xlGOhC1J947mqIdLaMy9YQKBgF7umonQbCF2AIV30a26gN+4ymasqG+aQzsOEqfKzf7X5Udf5Xk9QbEE6MCU2iTAM4hd2Rb/TlWJZRGjnQZ96+lqHrl+vpB1u/i4Njb9z0Sd2U8BIf7f5ZQSaaLoAAzbDqXk7H3Xmixq0xgklvTm3PK53JGxbR7QhHIfehPOshc3AoGBAI4c4sJZ7jU7Af5Q79S+9XCtkk5SFSgzuMwclaqjTBPjw9nsQO3sNgHQtSIDLy/CKpcjZt33ROfGBp41IBRP/tFFJFbTE6RSrXhlPiQVT9SThvxo0nJPSs95G2UN+eFpTzM2+nYLyg7tzxVPa76HDgghCflHj8+qhEb8+oEs0yeC";
/// <summary>
/// コンストラクタ
/// </summary>
/// <param name="configuration">設定</param>
public AuthServerController(IConfiguration configuration)
{
_Configuration = configuration;
// 本来は設定ファイルから取得するが
// テストサーバの用途的に、固定で書くことにする
// 発行者
_OidcIssure = "oidc_server";
//_OidcIssure = "AuthServer";
// Tokenを発行する対象のクライアント
_OidcClientId = "test_client";
// Blazor Web Assemblyを想定したURL
// 相対パスで書くと、ブラウザ側のベースURL + 相対パスでアクセスしてくれる
_OidcAuthority = "AuthServer";
// Token有効期限(秒) 1日(=24H)
//_OidcTokenTime = "86400";
_OidcTokenTime = "86400";
// Tokenリフレッシュ期限(秒) 1日(=24H)
//_OidcRefreshTime = "86400";
_OidcRefreshTime = "120";
// 後で設定する
_OidcServerAuthority = "";
}
/// <summary>
/// メタ情報取得
/// id_token取得用のURLなどのメタ情報を返却する
/// </summary>
/// <returns>メタ情報</returns>
[HttpGet(".well-known/openid-configuration")]
public OidcWellKnown MetaDataByWebAssembly()
{
OidcWellKnown oidcWellKnown = new OidcWellKnown();
oidcWellKnown.issuer = _OidcIssure;
// 相対パスで書くと、ブラウザ側のベースURL + 相対パスでアクセスしてくれる
oidcWellKnown.authorization_endpoint = _OidcAuthority + "/Auth";
oidcWellKnown.token_endpoint = _OidcAuthority + "/Token";
oidcWellKnown.userinfo_endpoint = _OidcAuthority + "/UserInfo";
oidcWellKnown.jwks_uri = _OidcAuthority + "Jwks";
//oidcWellKnown.end_session_endpoint = _OidcAuthority + "/Logout";
// リフレッシュトークンとイントロスペクションは未実装
oidcWellKnown.response_types_supported = new List<string>();
oidcWellKnown.response_types_supported.Add("code");
oidcWellKnown.response_types_supported.Add("id_token");
oidcWellKnown.response_types_supported.Add("token");
oidcWellKnown.response_types_supported.Add("token id_token");
return oidcWellKnown;
}
/// <summary>
/// メタ情報取得(Webサーバ -> OIDCサーバのAJax用)
/// ※WebAssembly(ブラウザ)でaccess_tokenを利用する際に使用する
/// access_tokenの場合はWebサーバでJWTのチェックのためにOIDCサーバにアクセス
/// するが、リダイレクトではなく直接参照するため、リバースプロキシ前のURL
/// でアクセスすると(大抵Webサーバはコンテナで動いているので)アクセスできない
/// なので、localhostに対してアクセスする
/// (kestrelで実行する際に、httpsとは別にサーバ用にhttpもポーリングしておく)
/// </summary>
/// <returns></returns>
[HttpGet("MetaDataByWebServer")]
public OidcWellKnown MetaDataByWebServer()
{
// 本来はhttp://localhost:ポート/ でアクセスするが、今回は割愛
// ※リバースプロキシを使わない
string baseUrl = Request.Scheme + "://"
+ this.Request.Host.Value + Request.PathBase;
if (baseUrl.EndsWith("/"))
{
baseUrl = baseUrl.TrimEnd('/');
}
// アプリサーバからアクセスする場合のURL
// (Blazorのサーバ側のBearerがアクセスする。またMVC側のサーバがアクセスする)
_OidcServerAuthority = baseUrl + "/AuthServer";
OidcWellKnown oidcWellKnown = new OidcWellKnown();
oidcWellKnown.issuer = _OidcIssure;
oidcWellKnown.authorization_endpoint = _OidcServerAuthority + "/Auth";
oidcWellKnown.token_endpoint = _OidcServerAuthority + "/Token";
oidcWellKnown.userinfo_endpoint = _OidcServerAuthority + "/UserInfo";
oidcWellKnown.jwks_uri = _OidcServerAuthority + "/Jwks";
//oidcWellKnown.end_session_endpoint = _OidcServerAuthority + "/Logout";
// リフレッシュトークンとイントロスペクションは未実装
oidcWellKnown.response_types_supported = new List<string>();
oidcWellKnown.response_types_supported.Add("code");
oidcWellKnown.response_types_supported.Add("id_token");
//oidcWellKnown.response_types_supported.Add("token");
//oidcWellKnown.response_types_supported.Add("token id_token");
return oidcWellKnown;
}
/// <summary>
/// 認可コード(code) Or id_token取得
/// 詳細は本ページトップの概要を参照のこと
/// </summary>
/// <param name="responseType">レスポンスタイプ code or id_token</param>
/// <param name="clientId">クライアントID</param>
/// <param name="redirectUri">コールバック先のURL</param>
/// <param name="state">state</param>
/// <param name="scope">スコープ</param>
/// <param name="nonce">nonce</param>
/// <param name="prompt">プロンプト</param>
/// <param name="codeChallenge">コードチャレンジ</param>
/// <param name="codeChallengeMethod">コードチャレンジメソッド</param>
/// <returns></returns>
[HttpGet("Auth")]
public IActionResult Auth(
[FromQuery(Name = "response_type")] string responseType
, [FromQuery(Name = "client_id")] string? clientId
, [FromQuery(Name = "redirect_uri")] string redirectUri
, [FromQuery(Name = "state")] string state
, [FromQuery(Name = "scope")] string? scope
, [FromQuery(Name = "nonce")] string? nonce
, [FromQuery(Name = "prompt")] string? prompt
, [FromQuery(Name = "code_challenge")] string? codeChallenge
, [FromQuery(Name = "code_challenge_method")] string? codeChallengeMethod)
{
// code_challenge : Base64の文字列
// code_challenge_method : S256
// response_mode : form_post
bool isError = false;
// client_id
if (_OidcClientId != clientId)
{
// client_id不一致時はエラー
isError = true;
}
if (isError)
{
Request.Method = "GET";
Response.StatusCode = 302;
return new RedirectResult(
redirectUri + "?error=access_denied" + "&state=" + state
, permanent: false, preserveMethod: false);
}
// response_typeはcodeとid_tokenがある
// id_tokenが必要な場合はid_tokenで呼び出し、
// access_tokenが必要な場合はcodeを呼出し後、tokenを呼び出す。
// なお、id_tokenが必要な場合でもcodeの呼び出し後tokenの呼び出しで取得できる。
if (responseType != null && responseType == "code")
{
if (prompt == "none")
{
// promptによるcodeの呼ばれ方は2種類あり
// prompt = noneの場合はログイン画面を表示せずに認可コードを返却する
// すでに他のサイトでログインしている前提で、本来はOpenIdConnectサーバは
// cookie等を使ってユーザーが他のサイトでシングルサインオンしているかを判断し
// シングルサインオンしている場合はログイン画面の表示なしで認可コードを返却する
// なお、ASP.NET COREでは、prompt=noneはAJax(WebAPI)で呼び出される
// シングルサインオンしていない場合はエラーを返す
Request.Method = "GET";
Response.StatusCode = 302;
return new RedirectResult(redirectUri + "?error=access_denied"
+ "&state=" + state, permanent: false, preserveMethod: false);
}
else
{
// 本来はセッションを確認してすでにログインしている場合は認可コードを返却する
// ログイン画面を表示して認証する
AuthServerViewModel viewModel = new AuthServerViewModel()
{
// 本来はセッション等に格納するが、テスト用のためhiddenで持ち回る
ClientId = clientId,
RedirectUri = redirectUri,
State = state,
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod,
Nonce = nonce,
ResponseType = responseType
};
return View("AuthLogin", viewModel);
}
}
else
{
// id_tokenで呼び出された場合、codeと同様にログイン処理を行う
// ログイン処理後、codeを返却するのではなく、id_tokenを返却する
// ログイン画面を表示して認証する
AuthServerViewModel viewModel = new AuthServerViewModel()
{
// 本来はセッション等に格納するが、テスト用のためhiddenで持ち回る
ClientId = clientId,
RedirectUri = redirectUri,
State = state,
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod,
Nonce = nonce,
ResponseType = responseType
};
return View("AuthLogin", viewModel);
}
}
/// <summary>
/// ログイン処理
/// </summary>
/// <param name="clientId">クライアントID</param>
/// <param name="redirectUri">コールバックURL</param>
/// <param name="state">state</param>
/// <param name="codeChallenge">コードチャレンジ</param>
/// <param name="codeChallengeMethod">コードチャレンジメソッド</param>
/// <param name="nonce">nonce</param>
/// <param name="responseType">レスポンスタイプ</param>
/// <param name="userId">ユーザID</param>
/// <returns></returns>
[HttpPost("AuthLogin")]
public IActionResult AuthLogin(
[FromForm(Name = "ClientId")] string? clientId
, [FromForm(Name = "RedirectUri")] string? redirectUri
, [FromForm(Name = "State")] string? state
, [FromForm(Name = "CodeChallenge")] string? codeChallenge
, [FromForm(Name = "CodeChallengeMethod")] string? codeChallengeMethod
, [FromForm(Name = "Nonce")] string? nonce
, [FromForm(Name = "ResponseType")] string? responseType
, [FromForm(Name = "UserId")] string? userId)
{
// codeにuserIdとstateを格納しておく(Tokenで使うため)
// 本来はcodeには適当な一意なIDを設定し、
// サーバ内でそのIDに紐づける形でuserIdとstateを保持する。
string code = $"{userId},{state},{codeChallenge},{codeChallengeMethod},{nonce}";
if (responseType == "code")
{
if (!string.IsNullOrEmpty(userId))
{
return new RedirectResult(
redirectUri + $"?code={code}&state={state}", false, false);
}
else
{
return new RedirectResult(
redirectUri + $"?error=access_denied&state={state}", false, false);
}
}
else
{
if (!string.IsNullOrEmpty(userId))
{
// id_token
OidcTokens token = CreateToken(userId, state, nonce);
// id_tokenをpostで返却したいところだが、Formにid_tokenを追加する方法がわからない。
// 以下はうまくいかなかった例
//Dictionary<string, StringValues> formDic = new Dictionary<string, StringValues>();
//formDic.Add("id_token", new StringValues(token.id_token));
//formDic.Add("state", new StringValues(token.session_state));
//formDic.Add("redirectUri", new StringValues(redirectUri));
//FormCollection form = new FormCollection(formDic);
//Request.Form = form;
// なのでいったんid_tokenを含んだ確認画面を表示し、確認画面からリダイレクトする
AuthServerViewModel viewModel = new AuthServerViewModel()
{
// 本来はセッション等に格納するが、テスト用のためhiddenで持ち回る
ClientId = clientId,
RedirectUri = redirectUri,
State = state,
Nonce = nonce,
ResponseType = responseType,
IdToken = token.id_token,
Error = ""
};
return View("AuthLoginConfirm", viewModel);
}
else
{
AuthServerViewModel viewModel = new AuthServerViewModel()
{
// 本来はセッション等に格納するが、テスト用のためhiddenで持ち回る
ClientId = clientId,
RedirectUri = redirectUri,
State = state,
Nonce = nonce,
ResponseType = responseType,
IdToken = "",
Error = "access_denied"
};
return View("AuthLoginConfirm", viewModel);
}
}
}
/// <summary>
/// ログイン確認画面からのサブミット
/// </summary>
/// <param name="redirectUri">コールバックURL</param>
/// <returns></returns>
[HttpPost("AuthLoginRedirect")]
public IActionResult AuthLoginRedirect(
[FromForm(Name = "RedirectUri")] string? redirectUri)
{
// Postで返却する
Request.Method = "POST";
Request.ContentType = "application/x-www-form-urlencoded";
return new RedirectResult(redirectUri, true, true);
}
/// <summary>
/// Token取得
/// Token取得はgrant_typeにより2つの形式で呼び出される
/// grant_typeが"refresh_token"の場合は、トークン有効期限切れのために
/// Tokenが呼び出される。
/// パラメータとして本処理で返却したrefresh_tokenが渡される
/// </summary>
/// <param name="redirectUri">コールバックURL</param>
/// <param name="clientId">クライアントID</param>
/// <param name="code">code</param>
/// <param name="refreshToken">リフレッシュトークン</param>
/// <param name="grantType">グラントタイプ</param>
/// <returns></returns>
[HttpPost("Token")]
public OidcTokens Token(
[FromForm(Name = "redirect_uri")] string? redirectUri
, [FromForm(Name = "client_id")] string? clientId
, [FromForm(Name = "code")] string? code
, [FromForm(Name = "code_verifier")] string? codeVerifier
, [FromForm(Name = "refresh_token")] string? refreshToken
, [FromForm(Name = "grant_type")] string? grantType)
{
bool isError = false;
// client_id
if (_OidcClientId != clientId)
{
// client_id不一致時はエラー
isError = true;
}
// 今回はテスト用のため、useridとstateをcode(またはrefresh_token)から取得する
// ※本当はcodeにはキー情報を設定し、認証したユーザIDなどはセッション等に格納しておく
// ※今回はrefresh_tokenにもcodeと同じ値を設定しているが、実際はid_tokenを設定し、
// id_tokenの署名の検証を行ったのち、id_tokenからユーザIDを取得する
string userId = "";
string state = "";
string codeChallenge = "";
string codeChallengeMethod = "";
string nonce = "";
string? authCode;
if (grantType == "refresh_token")
{
// トークンの有効期限切れのため再度Tokenが呼び出された
authCode = refreshToken;
}
// authorization_code
else
{
// 通常
authCode = code;
}
if (!isError)
{
if (!string.IsNullOrEmpty(authCode))
{
// テストなので、authCodeには
// userid,state,code_challenge,code_challenge_method,nonce
// の形式でデータを設定している
string[] work = authCode.Split(",");
userId = work[0];
// code -> tokenの流れの場合は、tokenにはstateを設定しない
//state = work[1];
state = "";
codeChallenge = work[2];
codeChallengeMethod = work[3];
nonce = work[4];
}
// useridが取得できなかった場合
if (string.IsNullOrEmpty(userId))
{
isError = true;
}
}
if (!isError)
{
if (!string.IsNullOrEmpty(codeChallenge)
&& !string.IsNullOrEmpty(codeVerifier))
{
// code_challengeの確認
// .netのcode_challenge_methodはS256なので、
// 手を抜いてcode_challenge_methodの判定を行わずS256で判定する
var sha256 = SHA256.Create();
byte[] codeVerifierHashByte = sha256.ComputeHash(
Encoding.UTF8.GetBytes(codeVerifier));
string codeVerifierBase64UInt = ConvertToBase64UInt(codeVerifierHashByte);
if (codeChallenge != codeVerifierBase64UInt)
{
// 一致しなかったらエラー
isError = true;
}
}
}
OidcTokens token;
if (!isError)
{
token = CreateToken(userId, state, nonce);
Request.Method = "GET";
// Jsonで返却する(ASP.NETがModelを勝手にJson形式に変換してくれる)
Response.ContentType = "application/json";
Response.StatusCode = 200;
}
else
{
token = new OidcTokens();
// 認証エラー
Response.ContentType = "application/json";
Response.StatusCode = 400;
token.error = "invalid_grant";
token.error_description = "user invalid";
token.error_uri = "";
}
return token;
}
/// <summary>
/// トークン電文作成
/// </summary>
/// <param name="userId">ユーザID</param>
/// <param name="state">state</param>
/// <param name="nonce">state</param>
/// <returns>トークン電文モデル</returns>
private OidcTokens CreateToken(string userId, string state, string nonce)
{
OidcTokens token = new OidcTokens();
// 秘密鍵を生成する
// RSA pem方式(Base64)
// openssl genrsa -out private.pem 2048
int readByte;
using RSA rsa = RSA.Create();
rsa.ImportRSAPrivateKey(Convert.FromBase64String(_OidcRsaPrivateKeyPem)
, out readByte);
// Claim(認証情報)の作成
ClaimsIdentity claimsIdentity = new ClaimsIdentity();
// ユーザID(一意に特定するためのIDで必須)
claimsIdentity.AddClaim(new Claim("sub", userId));
// ユーザの表示名で任意
claimsIdentity.AddClaim(new Claim(ClaimTypes.Name, userId));
//claimsIdentity.AddClaim(new Claim("role", "Admin"));
// nonce
claimsIdentity.AddClaim(new Claim("nonce", nonce));
// nonce
//claimsIdentity.AddClaim(new Claim("scope", "openid email profile"));
// JWTを生成する
SecurityTokenDescriptor descriptor = new SecurityTokenDescriptor();
// Audience Tokenを発行する対象のクライアント
descriptor.Audience = _OidcClientId;
// Tokenの発行者
descriptor.Issuer = _OidcIssure;
// Claim
descriptor.Subject = claimsIdentity;
// Tokenが有効になる日時(省略時は今すぐ)
descriptor.NotBefore = DateTime.Now;
// Tokenの有効期限(日時)
// Tokenの有効期限はTokenVaidateでチェックすると思われる
// Blazor WebAssemblyのブラウザサイドではチェックしていないようにみえる
// Blazor WebAssemblyのサーバサイド(JwtBearer)はValidateLifetime=trueにすることで
// チェックする?(未検証)
// Blazor WebAssemblyのブラウザサイドでは、Token返却に一緒に返却する
// expires_inをチェックしている
// expires_inが期限切れになると、Tokenのrefreshの電文が
// Blazor WebAssemblyのブラウザから発行される
descriptor.Expires = DateTime.Now.AddSeconds(double.Parse(_OidcTokenTime));
// 発行日
descriptor.IssuedAt = DateTime.Now;
// Jwtの暗号化(今回は行わない)
// Jwtには署名がつくが、ユーザIDなどの認証情報は平文(Base64)のままである。
// 署名は情報を隠すためではなく、改ざん防止のためにある
descriptor.EncryptingCredentials = null;
descriptor.SigningCredentials = new SigningCredentials(
// RSAキー
key: new RsaSecurityKey(rsa)
{
// 完全に参照元のサイトの受け売りだが、
// 破棄されないキャッシュがあるらしく
// 偶数リクエストで失敗するらしい
// そのため明示的にキャッシュをクリアするそうな
// TODO:後でキャッシュのクリアを外して確認する
CryptoProviderFactory = new CryptoProviderFactory()
{
CacheSignatureProviders = false
}
},
algorithm: SecurityAlgorithms.RsaSha256
);
// 上記の内容でTokenを作成する
JwtSecurityTokenHandler jwtHandler = new JwtSecurityTokenHandler();
var securityToken = jwtHandler.CreateJwtSecurityToken(descriptor);
//var securityToken = jwtHandler.CreateToken(descriptor);
// JwtSecurityTokenでないとヘッダを追加できない
securityToken.Header.Add("kid", _OidcClientId);
string tokenStr = jwtHandler.WriteToken(securityToken);
//var tokenStr = jwtHandler.CreateEncodedJwt(descriptor);
// Token電文を生成する
token.access_token = tokenStr;
token.id_token = tokenStr;
token.scope = "openid email profile";
token.expires_in = _OidcTokenTime;
// OpenIdConnectの場合はBearer固定
// このトークンを持っている=認可されているという意味になるそう
token.token_type = "Bearer";
//token.token_type = "BearerToken";
// クライアントがid_tokenを要求した際に送信してきたstateをそのまま返す
// クライアントはこれでリクエストに対するリダイレクトであると判定する
// ただし、code -> token時はtokenでstateを返してはいけない
if (string.IsNullOrEmpty(state))
{
token.state = null;
}
else
{
token.state = state;
}
// Tokenが有効になるまでの時間(秒)
// TokenのNotBeforeに加算されて使われる
token.not_before_policy = "0";
// Token有効期限切れの際にクライアントからOidCサーバへの
// Token電文のrefresh_tokenに設定される値
// 今回は手抜きで、codeと同じく、userIdとstateを設定しておく。
// 本来は、useridとstateをセッションに格納し、セッションに紐づく一意なキーを返却する
string code = $"{userId},{state},,,";
token.refresh_token = code;
// Refresh時間(秒)
// Expires_inよりも短く設定した場合の検証はやっていない
token.refresh_expires_in = _OidcRefreshTime;
return token;
}
/// <summary>
/// ユーザ情報取得
/// </summary>
/// <returns></returns>
[HttpGet("UserInfo")]
[Authorize]
public OidcUserInfo UserInfo()
{
OidcUserInfo userInfo = new OidcUserInfo();
userInfo.name = "dummy";
userInfo.iss = _OidcIssure;
userInfo.aud = _OidcClientId;
userInfo.sub = this.User.Identity?.Name;
Response.ContentType = "application/json";
Response.StatusCode = 200;
return userInfo;
}
/// <summary>
/// ログアウト(未実装)
/// </summary>
/// <returns></returns>
[HttpGet("Logout")]
public string Logout()
{
return "";
}
/// <summary>
/// ログイン失敗用
/// </summary>
/// <returns></returns>
[HttpGet("Fail")]
public IActionResult Fail()
{
return this.Challenge();
}
/// <summary>
/// 公開鍵情報取得
/// </summary>
/// <returns></returns>
[HttpGet("Jwks")]
public OidcKeys Jwks()
{
// RSA公開鍵の指数(e)とModulus(n)の作り方
// 秘密鍵をもとに公開鍵を作る
// openssl rsa -in private.pem -outform PEM -pubout -out public.pem
// 公開鍵から指数とModulusを作る
// openssl rsa -in public.pem -pubin -text -noout
// (modulusは最初の00:を除く)
// (指数は出力された数字をBASE64変換して使う)
// modulusは以下のコマンドで出力する(openssl --modulusで16進数文字列で出力されるため
// それをBase64UrlUIntに変換する)
// openssl rsa -in public.pem -pubin -noout -modulus | cut -d= -f2 | xxd -r -p | base64 -w 0
// 秘密鍵から公開鍵のModulus(n)と指数(e)を生成する
int readByte;
using RSA rsa = RSA.Create();
rsa.ImportRSAPrivateKey(Convert.FromBase64String(_OidcRsaPrivateKeyPem)
, out readByte);
var publicParam = rsa.ExportParameters(includePrivateParameters: false);
//ModulusはBase64UrlUIntに変換する
//通常のBase64に加え、+を-, /を_に置換し、末尾の==を除去したもの
// RSAParametersのModulusをバイト配列に変換
var modulusBytes = publicParam.Modulus;
// バイト配列をBase64UIntにエンコード
var modulusBase64UInt = ConvertToBase64UInt(modulusBytes);
// 指数
var exponentBase64UInt = ConvertToBase64UInt(publicParam.Exponent);
OidcKeyInfo keyInfo = new OidcKeyInfo();
keyInfo.kty = "RSA";
keyInfo.alg = SecurityAlgorithms.RsaSha256;
keyInfo.use = "sig";
// 指数 65537をBase64変換したもの
keyInfo.e = exponentBase64UInt;
//keyInfo.e = "65537";
// Modulus
keyInfo.n = modulusBase64UInt;
// このキーIDとtokenのkidは合わせる
keyInfo.kid = _OidcClientId;
OidcKeys keys = new OidcKeys();
keys.Keys = new List<OidcKeyInfo>();
keys.Keys.Add(keyInfo);
//Microsoft.IdentityModel.Tokens.JsonWebKey aa = new JsonWebKey();
return keys;
}
/// <summary>
/// Base64UInt変換
/// </summary>
/// <param name="data">バイト</param>
/// <returns>Base64UInt文字列</returns>
private string ConvertToBase64UInt(byte[] data)
{
// バイト配列をBase64にエンコード
var dataBase64 = Convert.ToBase64String(data);
// Base64の文字列から、+と/をそれぞれ-と_に置き換え
var dataBase64UInt = dataBase64.Replace('+', '-').Replace('/', '_');
// Base64の文字列の末尾の=を削除
dataBase64UInt = dataBase64UInt.TrimEnd('=');
return dataBase64UInt;
}
}
}
OidcKeyInfo.cs
公開鍵情報返却用です。.NETがJSON形式にして返却します。
namespace TestWebSv.Models.AuthServer.JsonModel
{
public class OidcKeyInfo
{
public string? kty { get; set; }
public string? alg { get; set; }
public string? use { get; set; }
public string? n { get; set; }
public string? e { get; set; }
public string? kid { get; set; }
}
}
OidcKeys.cs
公開鍵情報返却用。公開鍵情報は複数返却することができるので、配列構造になっています。
namespace TestWebSv.Models.AuthServer.JsonModel
{
public class OidcKeys
{
public List<OidcKeyInfo> Keys { get; set; }
}
}
OidcTokens.cs
トークン返却用。
using System.Text.Json.Serialization;
namespace TestWebSv.Models.AuthServer.JsonModel
{
public class OidcTokens
{
public string? access_token { get; set; }
public string? expires_in { get; set; }
public string? refresh_expires_in { get; set; }
public string? refresh_token { get; set; }
public string? token_type { get; set; }
public string? id_token { get; set; }
[JsonPropertyName("not-before-policy")]
public string? not_before_policy { get; set; }
public string? state { get; set; }
public string? scope { get; set; }
public string? error { get; set; }
public string? error_description { get; set; }
public string? error_uri { get; set; }
}
}
OidcUserInfo.cs
ユーザ情報返却用。
namespace TestWebSv.Models.AuthServer.JsonModel
{
public class OidcUserInfo
{
public string? sub { get; set; }
public string? iss { get; set; }
public string? aud { get; set; }
public string? name { get; set; }
}
}
OidcWellKnown.cs
メタ情報返却用。
namespace TestWebSv.Models.AuthServer.JsonModel
{
public class OidcWellKnown
{
public string? issuer { get; set; }
public string? authorization_endpoint { get; set; }
public string? token_endpoint { get; set; }
public string? jwks_uri { get; set; }
public List<string>? response_types_supported { get; set; }
public string? introspection_endpoint { get; set; }
public string? userinfo_endpoint { get; set; }
public string? end_session_endpoint { get; set; }
}
}
AuthServerViewModel.cs
Oidcのログイン画面で入力されたユーザIDや、ユーザIDをもとに生成したトークンをHTMLのHidden項目で持ち回るためのモデル。
#nullable enable
using System.ComponentModel.DataAnnotations;
using System.ComponentModel;
namespace TestWebSv.Models.AuthServer.ViewModel
{
public class AuthServerViewModel
{
/// <summary>
/// エラー
/// </summary>
[DisplayName("エラー")]
public string? Error { get; set; }
/// <summary>
/// ユーザーID
/// </summary>
[DisplayName("ユーザーID")]
public string? UserId { get; set; }
/// <summary>
/// クライアントID(持ち回り用)
/// </summary>
public string? ClientId { get; set; }
/// <summary>
/// State(持ち回り用)
/// </summary>
[DisplayName("State")]
public string? State { get; set; }
/// <summary>
/// nonce(持ち回り用)
/// </summary>
public string? Nonce { get; set; }
/// <summary>
/// code_challenge(持ち回り用)
/// </summary>
public string? CodeChallenge { get; set; }
/// <summary>
/// code_challenge_method(持ち回り用)
/// </summary>
public string? CodeChallengeMethod { get; set; }
/// <summary>
/// リダイレクト(持ち回り用)
/// </summary>
public string? RedirectUri { get; set; }
/// <summary>
/// レスポンスタイプ(持ち回り用)
/// </summary>
public string? ResponseType { get; set; }
/// <summary>
/// idトークン(持ち回り用)
/// </summary>
[DisplayName("IDトークン")]
public string? IdToken { get; set; }
}
}
AuthLogin.cshtml
OIDCのログイン画面。
@model TestWebSv.Models.AuthServer.ViewModel.AuthServerViewModel
@{
ViewBag.Title = "ログイン画面";
}
<div class="form-horizontal">
@using (Html.BeginForm("AuthLogin", "AuthServer"))
{
@Html.AntiForgeryToken()
@Html.HiddenFor(model => model.RedirectUri)
@Html.HiddenFor(model => model.ClientId)
@Html.HiddenFor(model => model.State)
@Html.HiddenFor(model => model.Nonce)
@Html.HiddenFor(model => model.CodeChallenge)
@Html.HiddenFor(model => model.CodeChallengeMethod)
@Html.HiddenFor(model => model.ResponseType)
<div class="form-group">
@Html.ValidationSummary(true, "", new { @class = "text-danger" })
</div>
<div>
ユーザIDを入力しなければ、認証エラーになります。
</div>
<div class="form-group">
@Html.LabelFor(model => model.UserId
, htmlAttributes: new { @class = "control-label col-xs-3" })
<div class="col-xs-8">
@Html.EditorFor(model => model.UserId
, new { htmlAttributes = new { @class = "form-control" } })
</div>
</div>
<div class="form-group">
<div class="col-xs-offset-3 col-xs-8">
<input type="submit" value="ログイン" class="btn btn-default" />
</div>
</div>
}
</div>
AuthLoginConfirm.cshtml
OIDCのログイン確認画面。id_tokenはPOSTで返却する必要があるが、ログイン画面からダイレクトでPOSTする方法がわからなかったので、ログイン確認画面にトークンを埋め込んで、確認画面のサブミットでPOSTでWEBサーバにリダイレクトされるようにしています。
@model TestWebSv.Models.AuthServer.ViewModel.AuthServerViewModel
@{
ViewBag.Title = "ログイン確認画面";
}
<div class="form-horizontal">
@using (Html.BeginForm("AuthLoginRedirect", "AuthServer"))
{
@Html.AntiForgeryToken()
@Html.HiddenFor(model => model.RedirectUri)
@Html.Hidden("state", Model.State)
@if (string.IsNullOrEmpty(Model.Error))
{
@Html.Hidden("id_token", Model.IdToken)
}
else
{
@Html.Hidden("error", Model.Error)
}
<table>
<tr>
<td>
<div class="form-group">
@Html.LabelFor(m => m.IdToken)
@Html.DisplayFor(m => m.IdToken)
</div>
</td>
</tr>
<tr>
<td>
<div class="form-group">
@Html.LabelFor(m => m.State)
@Html.DisplayFor(m => m.State)
</div>
</td>
</tr>
<tr>
<td>
<div class="form-group">
@Html.LabelFor(m => m.Error)
@Html.DisplayFor(m => m.Error)
</div>
</td>
</tr>
<tr>
<td>
<div class="form-group">
<div class="col-xs-offset-3 col-xs-8">
<input type="submit" value="IDトークン送信" class="btn btn-default" />
</div>
</div>
</td>
</tr>
</table>
}
</div>
WEBサーバの設定
テスト用のOIDCサーバを呼び出すためのプログラムになります。
Program.cs
Program.csから、AddOpenIdConnectを呼び出し、ログイン認証にOIDCが使用されるようにします。
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
var builder = WebApplication.CreateBuilder(args);
var mvcBuilder = builder.Services.AddControllersWithViews();
builder.Services.Configure<CookiePolicyOptions>(options =>
{
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.None;
options.HttpOnly = Microsoft.AspNetCore.CookiePolicy.HttpOnlyPolicy.Always;
});
// ベースURL
// ※後続のapp.Useで値を設定する
string baseUrl = "";
AuthenticationBuilder authBuilder = builder.Services.AddAuthentication(options =>
{
// デフォルトの認証はCookie認証だが、ログインはOpenIdConnectになる
// なぜCookie認証が必要かというとOpenIdConnectで認証したユーザ認証情報は
// (デフォルトでは)Cookie認証として保存されるため、認可(権限)チェックは
// Cookie認証として行われるためである
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
});
authBuilder.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
// ロールが権限不足だった際の遷移先
// OpenIdConnectがid_tokenを元にClaimを作ってcookie(デフォルト)に保存しているため、
// 権限不足の場合の挙動はCookie認証になる
options.AccessDeniedPath = "/Home/ErrorDeny";
});
authBuilder.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
string clientId = "test_client";
string issuer = "oidc_server";
// クライアントの設定
options.AccessDeniedPath = "/Home/ErrorDeny";
options.ClientId = clientId;
// code または id_token
//options.ResponseType = OpenIdConnectResponseType.Code;
options.ResponseType = OpenIdConnectResponseType.IdToken;
// baseUrlは自分自身のURL
// (Webサーバと同じサーバにテスト用のOIDCサーバがあるため)
// なお、AddOpenIdConnectのoptionsは、UseAuthenticationが呼び出される際に
// 展開されるため、app.Useで設定したbaseUrlの値を使用することができる
options.MetadataAddress = $"{baseUrl}/AuthServer/MetaDataByWebServer";
options.Authority = $"{baseUrl}/AuthServer";
// Tokenの保存先はcookieになる
options.SaveTokens = true;
// Identity保存先スキーマ(デフォルトはAddAuthenticationのDefaultScheme(今回はCookie))
// なお、cookieに保存されるのはID情報だけではなく、id_tokenなども保存される
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// リクエストのたびにid_tokenのExpires(有効期限)をチェックし、有効期限が切れていたら
// 再度id_tokenを要求する(デフォルト)
options.UseTokenLifetime = true;
options.ResponseMode = OpenIdConnectResponseMode.FormPost;
// Configurationを設定するとConfigurationが優先され、MetadataAddressが無視される
// ので注意
//options.Configuration = new OpenIdConnectConfiguration
//{
// //AuthorizationEndpoint = "https://localhost:7257/AuthServer/Auth",
// //TokenEndpoint = "https://localhost:7257/AuthServer/Token",
// Issuer = "oidc_server",
//};
var validParam = options.TokenValidationParameters;
validParam.ValidateIssuer = true;
validParam.ValidIssuer = issuer;
validParam.ValidateIssuerSigningKey = true;
validParam.ValidateAudience = true;
validParam.ValidAudience = clientId;
validParam.ValidateLifetime = true;
validParam.NameClaimType = ClaimTypes.NameIdentifier;
options.ProtocolValidator.RequireNonce = true;
options.Events = new OpenIdConnectEvents()
{
OnRedirectToIdentityProvider = context =>
{
return Task.FromResult(0);
},
OnTokenValidated = async c =>
{
// ロールを追加する
ClaimsIdentity clmIdentity = new ClaimsIdentity("AuthenticationTypes.Federation");
// ユーザIDをもとにRoleを(DB等から)取得する
// 今回はテストなので割愛する
string role = "Administrator";
clmIdentity.AddClaim(new Claim(ClaimTypes.Role, role));
// ロールを追加する
// ここで追加しておくと、ID情報と一緒にcookieに保存してくれる
c.Principal.AddIdentity(clmIdentity);
return;
},
// こんな感じでイベントが使える
OnTicketReceived = c =>
{
return Task.FromResult(0);
},
OnAuthorizationCodeReceived = c =>
{
return Task.FromResult(0);
},
OnAccessDenied = c =>
{
return Task.FromResult(0);
},
OnAuthenticationFailed = c =>
{
return Task.FromResult(0);
},
OnTokenResponseReceived = c =>
{
// サンプル:返却されたid_tokenの参照の仕方
string token = c.TokenEndpointResponse.AccessToken;
JwtSecurityTokenHandler jwtHandler = new JwtSecurityTokenHandler();
var securityToken = jwtHandler.ReadToken(token);
return Task.FromResult(0);
},
OnMessageReceived = c =>
{
return Task.FromResult(0);
}
};
});
builder.Services.AddHttpContextAccessor();
var app = builder.Build();
//app.UsePathBase("/");
// リバースプロキシを使っている場合に、リバースプロキシサーバが書き換える前のURLに戻す
// 例) [リバースプロキシ] https://abcdef.co.jp:444/ -> [Webサーバ] http://localhost:5000/
// AWSのALBは、X-Forwarded-ProtoとX-Forwarded-Port、X-Forwarded-Forに対応している
// 対して、.NETはX-Forwarded-Portに対応していない
// なので、リバースプロキシが書き換える前のURLを生成しようとすると、
// https://abcdef.co.jp/ になってしまう。
// これで困るのは、OIDCのCallBackのURLで、https://abcdef.co.jp/にCall Backされてしまう
// なので、X-Forwarded-HostにX-Forwarded-Portの内容を追加する
// そもそもhttpsなので443で呼び出せば問題ないのだが、
// テスト環境などで諸般の事情により443を使えない場合がある
app.Use(async (context, next) =>
{
// X-Forwarded-Portを取得する
string forwardedPort = context.Request.Headers["X-Forwarded-Port"];
if (!string.IsNullOrEmpty(forwardedPort))
{
// ホスト名を取得する
string host = context.Request.Headers["X-Forwarded-Host"];
if (string.IsNullOrEmpty(host))
{
host = context.Request.Host.Host;
}
host = $"{host}:{forwardedPort}";
context.Request.Headers["X-Forwarded-Host"] = host;
}
await next();
});
// XForwardedHostを指定することでPortも書き変わる
app.UseForwardedHeaders(new ForwardedHeadersOptions()
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor
| ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost
});
// テスト用OIDCサーバを同じプロジェクト内に配置するので、自分自身のベースURLを取得する
// ※UseAuthenticationを呼び出す前に行うこと
// このbaseUrlは、AddOpenIdConnect内で使用する
app.Use(async (context, next) =>
{
// スキーマ(https)
string scheme = context.Request.Scheme;
// ホスト(含むポート)
string host = context.Request.Host.Value;
// ベース
string pathBase = context.Request.PathBase;
// URL
baseUrl = $"{scheme}://{host}{pathBase}".TrimEnd('/');
await next();
});
app.UseExceptionHandler("/Home/Error");
//app.UseDeveloperExceptionPage();
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
// 認証
app.UseAuthentication();
// 認可
app.UseAuthorization();
app.UseStatusCodePages(context =>
{
if (context.HttpContext.Response.StatusCode != 401)
{
//context.HttpContext.Response.Redirect("/Error/Error");
}
return Task.CompletedTask;
});
app.Use(async (context, next) =>
{
Console.WriteLine(context.Request.GetDisplayUrl());
await next();
});
app.Run();
HomeController.cs
HomeとPrivacyページを認証していないとアクセスできないように修正。
また、Privacyについては、ロール不一致で権限エラーになるように設定。
エラーページのほかに権限エラーページ(ErrorDenied)を追加。
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;
using TestWebSv.Models;
namespace TestWebSv.Controllers
{
public class HomeController : Controller
{
private readonly ILogger<HomeController> _logger;
public HomeController(ILogger<HomeController> logger)
{
_logger = logger;
}
// ログイン済でかつRoleがAdministratorしかアクセスできない
// 複数ロール設定する場合は、カンマで区切る
[Authorize(Roles = "Administrator")]
public IActionResult Index()
{
return View();
}
// ログイン済でかつRoleがAdministrator2しかアクセスできない
// ※エラーになる
[Authorize(Roles = "Administrator2")]
public IActionResult Privacy()
{
return View();
}
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
public IActionResult ErrorDeny()
{
return View("Error", new ErrorViewModel { RequestId = "ErrorDeny" });
}
}
}
Error.cshtml
権限エラーを追加。
@model TestWebSv.Models.ErrorViewModel
@{
ViewData["Title"] = "Error";
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.RequestId == "ErrorDeny")
{
<p>
<strong>権限エラー</strong>
</p>
}
else
{
<p>
<strong>普通のエラー</strong>
</p>
if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>