Skip to content

プリザンターのログインを外部アプリの SSO に使う ​

第1版作成 最終更新 (日本時間)
確認バージョン1.5.8.1

プリザンターにログイン済みの利用者が、そのログイン状態で外部の業務アプリにも入れるようにします。外部アプリのサーバーが利用者の Cookie をプリザンターの標準 API に渡し、「この利用者は誰か」をプリザンター自身に確認する方式です。

本人確認をプリザンターに任せることで、IdP 代わりに利用できます。 外部アプリではパスワードを受け取らず、確認できた利用者に対して独自のセッションを発行します。プリザンター本体の改造や、認証 Cookie の復号・暗号鍵の共有は不要です。

ここで紹介するのは、既存の Cookie と API を組み合わせる独自の SSO です。プリザンターに SAML/OIDC の IdP 機能やトークン発行機能を追加するものではありません。外部アプリ側に、以下の本人確認・認可・セッション管理を実装します。

この方式を使うための配置条件

既定の Cookie を使うこの方式では、同じ公開ホスト名で、外部アプリをプリザンターの Cookie の Path 配下に配置します。 プリザンターはルートでもサブパスでも構いません。本文の forms.example.com と /myapp/ は例示値です。実際の公開ホスト名と配置パスに読み替えてください。

最初に確認する制約:同一ホストと URL の階層 ​

「同一ホスト」は、ブラウザのアドレス欄に出るホスト名が同じという意味です。同じ物理サーバーや同じ IP アドレスでも、公開ホスト名が違えば条件を満たしません。反対に、裏側のサーバーやコンテナは別でも、リバースプロキシで一つの公開ホストにまとめられます。

「サブ階層」は URL のパスを指します。プリザンターのプログラムのフォルダー内に外部アプリをコピーする必要はありません。

次の図は、プリザンターを /、外部アプリを /myapp/ に公開する例です。

図を読み込み中…

実線はブラウザからの要求、点線はサーバー間通信です。Cookie の送信範囲を決める公開 URL と、API を呼ぶ内部 URL は区別します。

用途URL の例アクセス元
プリザンターのログイン画面https://forms.example.com/users/loginブラウザ
外部アプリhttps://forms.example.com/myapp/ブラウザ
プリザンターの内部 URLhttp://127.0.0.1:8080/(同一マシンの例)外部アプリのサーバー

内部 URL は公開ホスト名と違っても構いません。 ただし、内部 URL の設定では、ブラウザから外部アプリへ Cookie が届かない問題は解決できません。

ルート配置も、サブパスの親子配置も使える ​

プリザンターの既定の認証 Cookie はホスト単位で、Path はプリザンター自身の配置パスに従います。必要なのは、外部アプリへの要求がその送信範囲に入ることです。

プリザンターの公開 URL外部アプリの公開 URLCookie の送信条件
https://forms.example.com/https://forms.example.com/myapp/満たす。Path / の配下
https://forms.example.com/pleasanter/https://forms.example.com/pleasanter/myapp/満たす。Path /pleasanter の配下
https://pleasanter.example.com/https://app.example.com/満たさない。公開ホスト名が違う
https://forms.example.com/pleasanter/https://forms.example.com/myapp/満たさない。兄弟パスであり範囲外
https://forms.example.com/pleasanter/https://forms.example.com/満たさない。親のパスには送られない

図を読み込み中…

この図はホスト名と Path の条件に絞っています。HTTPS など、ほかの Cookie の送信条件も満たす必要があります。Cookie はポートで区別されないため localhost のポート違いでも届きますが、それだけでは本番のパス配置の確認にはなりません。

プリザンターを /pleasanter/ に置く場合、ログイン URL は /pleasanter/users/login、外部アプリはたとえば /pleasanter/myapp/ です。プロキシでは、より深い外部アプリの経路を優先して振り分けます。プリザンターのルート配置も、myapp というパス名も必須ではありません。

公開パスとアプリが認識するパスをそろえる ​

外部アプリは、自分の公開パスを前提に画面・API・リダイレクト先・セッション Cookie の Path を組み立てます。サブパスをそのまま受け取る構成なら、プロキシでも接頭辞を保持します。接頭辞を削って転送する構成を選ぶ場合は、利用するフレームワークの仕組みで公開パスを認識させます。接頭辞を削るだけでは、404 や誤ったリダイレクト先の原因になります。

本人確認用の内部 URL は、サーバーから実際にプリザンターへ到達するベース URL にします。内部でも /pleasanter/ に配置されていれば http://127.0.0.1:8080/pleasanter/、内部ではルートなら http://127.0.0.1:8080/ です。以降の API パスは、このベース URL の末尾に追加します。

API と通信の前提 ​

  • 対象はプリザンター 1.5.8.1、要求の ApiVersion は 1.1、Api.json の Compatibility_1_3_12 は false です。
  • 紹介する Cookie による API 呼び出しは、Security.json の TokenCheck が false の環境を前提とします。
  • IP 制限がある場合は、API を呼ぶ外部アプリのサーバーを許可します。
  • 公開経路は HTTPS、サーバー間も Cookie を保護できる経路にします。Cookie を受け取る外部アプリは、プリザンターと同じ信頼範囲で運用します。

根拠は Cookie 認証の設定 と セッション Cookie の Path 設定 です。

実装する処理の全体像 ​

外部アプリに必要なのは、Cookie を受け取って本人を確認する処理と、その結果から自分のセッションを作る処理です。

図を読み込み中…

プリザンターに未ログインなら、外部アプリからプリザンターのログイン画面へ案内し、ログイン後に外部アプリで本人確認をやり直します。ログイン画面を別窓で開く方法もあります。画面を閉じたことや URL の利用者 ID だけで成功とせず、必ずサーバーから API を呼んで確定します。

サーバーから POST {プリザンターのベース URL}/api/users/get を呼びます。Content-Type: application/json と Accept: application/json を指定し、利用者のプリザンター用 Cookie を Cookie ヘッダーに付けます。本文は次の JSON です。ユーザー取得 API

json
{
  "ApiVersion": 1.1,
  "View": {
    "ColumnFilterHash": {
      "UserId": "[\"Own\"]"
    }
  }
}

Own はプリザンター側でログイン中の利用者 ID に置き換わります。ブラウザから申告された ID ではなく、Cookie から解決された本人を取得できる点が、この方式の中心です。

この要求には API キーを付けません。 API キーを付けると、そのキーの利用者が優先され、ブラウザの本人を確認する要求ではなくなります。

応答のうち使う部分を抜き出すと、次の形です。値は説明用です。

json
{
  "StatusCode": 200,
  "Response": {
    "TotalCount": 1,
    "Data": [
      {
        "TenantId": 1,
        "UserId": 123,
        "LoginId": "user-example",
        "Name": "利用者の表示名",
        "DeptId": 10
      }
    ]
  }
}

HTTP の成功だけで判断せず、JSON の StatusCode が 200、TotalCount と実際の行数がともに 1、TenantId・UserId が正の整数、LoginId が空でないことを確認します。想定するテナントとの一致も確認し、0 件・複数件・不正な応答ではセッションを発行しません。

根拠: API キーとログイン利用者の解決、Own の変換。

転送する Cookie は、プリザンターの認証に必要なものだけを許可します。既定名の例は .AspNetCore.Cookies と Pleasanter_SessionGuid です。認証 Cookie が分割される場合は .AspNetCore.CookiesC1 などのチャンクも対象にします。実際の Cookie 名を確認し、外部アプリ自身の Cookie は除外します。

共有 HTTP クライアントに利用者ごとの Cookie を保存すると、別の利用者の要求へ混ざる危険があります。Cookie は各要求へ明示的に付け、クライアント側の自動保存を無効にします。ASP.NET Core の HttpClientHandler なら UseCookies = false、自動リダイレクトは AllowAutoRedirect = false とする構成です。

問い合わせ先をサーバー設定で固定し、ブラウザが指定した任意の URL へ Cookie を送らないようにします。リダイレクト、ログイン画面の HTML、タイムアウトを認証成功として扱わず、Cookie や API キーの値もログへ残しません。

C# サンプル:本人確認からセッション発行まで ​

ASP.NET Core Minimal API(.NET 10)で、通常経路を実装する例です。追加の NuGet パッケージは不要です。プリザンターの Cookie を転送し、本人を確認してから外部アプリ専用の Cookie を発行します。

この例の利用条件は、設定したテナントとユーザー ID の許可リストへの一致です。許可リストが空なら誰も通しません。組織・グループで絞る場合は、コード内の「認可」の位置に後述の所属判定を組み込みます。

これは通常経路の動作を確認する最小サンプルです。 アプリのセッションは固定 10 分で切れますが、プリザンターへの定期的な再検証、アプリ独自の MFA、ログアウト画面、API 制限時の代替経路は含めていません。実運用では、本記事の各節に沿って追加してください。特に、このままではプリザンターからログアウトしても、発行済みのアプリセッションは有効期限まで残ります。

プロジェクトと接続先を用意する ​

powershell
dotnet new web -n PleasanterSsoSample -f net10.0
cd PleasanterSsoSample

生成された appsettings.json を次の内容に置き換えます。BaseUrl はサーバーからプリザンターへ届く URL で、末尾の / を含めます。TenantId と AllowedUserIds は、利用を許可する実際の ID に変更してください。ここに API キーは設定しません。

json
{
  "Pleasanter": {
    "BaseUrl": "http://127.0.0.1:8080/",
    "TenantId": 1,
    "AllowedUserIds": [123]
  }
}

Program.cs ​

生成された Program.cs を、次のコード全体で置き換えます。Cookie 名はプリザンターの既定名を使っています。変更している環境では SelectCookies の許可対象も合わせて変更してください。

Program.cs の全文
csharp
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;

var builder = WebApplication.CreateBuilder(args);
var baseUrl = new Uri(builder.Configuration["Pleasanter:BaseUrl"]
    ?? throw new InvalidOperationException("Pleasanter:BaseUrl is required."));
var tenantId = builder.Configuration.GetValue<int>("Pleasanter:TenantId");
var allowedUsers = builder.Configuration.GetSection("Pleasanter:AllowedUserIds")
    .Get<int[]>() ?? [];
// このサンプルは /myapp で公開し、プロキシも接頭辞を保持する構成。
const string appPath = "/myapp";
if (!baseUrl.IsAbsoluteUri || baseUrl.Scheme is not ("http" or "https")
    || !baseUrl.AbsolutePath.EndsWith('/') || tenantId <= 0)
    throw new InvalidOperationException("Invalid Pleasanter configuration.");

builder.Services.AddHttpClient("Pleasanter", client =>
{
    client.BaseAddress = baseUrl;
    client.Timeout = TimeSpan.FromSeconds(5);
    client.MaxResponseContentBufferSize = 1024 * 1024;
    client.DefaultRequestHeaders.Accept.Add(
        new MediaTypeWithQualityHeaderValue("application/json"));
}).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
    UseCookies = false,
    AllowAutoRedirect = false
});
builder.Services.AddAuthentication("App").AddCookie("App", options =>
{
    options.Cookie.Name = "MyApp.Session";
    options.Cookie.Path = appPath;
    options.Cookie.HttpOnly = true;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    options.Cookie.SameSite = SameSiteMode.Lax;
    options.ExpireTimeSpan = TimeSpan.FromMinutes(10);
    options.SlidingExpiration = false;
    options.Events.OnRedirectToLogin = context =>
    {
        context.Response.StatusCode = 401;
        return Task.CompletedTask;
    };
});
builder.Services.AddAuthorization();
builder.Services.AddAntiforgery(options =>
{
    options.Cookie.Name = "MyApp.Antiforgery";
    options.Cookie.Path = appPath;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});

var app = builder.Build();
// 既定の信頼対象(ループバック)のプロキシから HTTPS 情報を受け取る。
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
    ForwardedHeaders = ForwardedHeaders.XForwardedProto
});
app.UsePathBase(appPath);
app.Use(async (context, next) =>
{
    if (context.Request.PathBase != appPath)
    {
        context.Response.StatusCode = 404;
        return;
    }
    await next();
});
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

// プリザンターにログインした後、この画面から SSO を実行する。
app.MapGet("/login", (HttpContext context, IAntiforgery antiforgery) =>
{
    var token = antiforgery.GetAndStoreTokens(context);
    var encode = HtmlEncoder.Default;
    context.Response.Headers.CacheControl = "no-store";
    return Results.Content($"""
        <form method="post" action="{appPath}/sso">
          <input type="hidden" name="{encode.Encode(token.FormFieldName)}"
                 value="{encode.Encode(token.RequestToken!)}">
          <button type="submit">プリザンターのログインで入る</button>
        </form>
        """, "text/html; charset=utf-8");
});

app.MapPost("/sso", async (HttpContext context, IAntiforgery antiforgery,
    IHttpClientFactory clients) =>
{
    try
    {
        await antiforgery.ValidateRequestAsync(context);
    }
    catch (AntiforgeryValidationException)
    {
        return Results.BadRequest();
    }

    // 再ログインに失敗した場合に、以前のアプリセッションを残さない。
    await context.SignOutAsync("App");
    var cookie = SelectCookies(context.Request.Headers.Cookie.ToString());
    if (cookie.Length == 0) return Results.Unauthorized();

    try
    {
        using var request = new HttpRequestMessage(HttpMethod.Post, "api/users/get");
        request.Headers.Add("Cookie", cookie);
        // 本人を確認するため、ApiKey は付けない。
        request.Content = JsonContent.Create(new
        {
            ApiVersion = 1.1,
            View = new { ColumnFilterHash = new { UserId = "[\"Own\"]" } }
        }, options: new JsonSerializerOptions { PropertyNamingPolicy = null });
        using var response = await clients.CreateClient("Pleasanter")
            .SendAsync(request, context.RequestAborted);
        if (response.StatusCode == HttpStatusCode.Unauthorized)
            return Results.Unauthorized();
        if (response.StatusCode == HttpStatusCode.Forbidden)
            return Results.StatusCode(403);
        if (!response.IsSuccessStatusCode) return Results.StatusCode(503);

        var result = await response.Content.ReadFromJsonAsync<ApiResult>(
            cancellationToken: context.RequestAborted);
        if (result?.StatusCode == 401) return Results.Unauthorized();
        if (result?.StatusCode == 403) return Results.StatusCode(403);
        if (result?.StatusCode != 200 || result.Response?.TotalCount != 1
            || result.Response.Data is not { Length: 1 })
            return Results.StatusCode(503);

        var user = result.Response.Data[0];
        if (user is null || user.TenantId <= 0 || user.UserId <= 0
            || string.IsNullOrWhiteSpace(user.LoginId))
            return Results.StatusCode(503);

        // 認可:この例はテナントとユーザー ID の許可リストを使う。
        if (user.TenantId != tenantId || !allowedUsers.Contains(user.UserId))
            return Results.StatusCode(403);

        var identity = new ClaimsIdentity(new[]
        {
            new Claim(ClaimTypes.NameIdentifier, $"{user.TenantId}:{user.UserId}"),
            new Claim(ClaimTypes.Name, user.LoginId)
        }, "App");
        await context.SignInAsync("App", new ClaimsPrincipal(identity));
        return Results.Redirect($"{appPath}/me");
    }
    catch (Exception ex) when (ex is HttpRequestException or JsonException
        or OperationCanceledException)
    {
        return Results.StatusCode(503);
    }
});

app.MapGet("/me", (ClaimsPrincipal user) => Results.Ok(new
{
    Id = user.FindFirstValue(ClaimTypes.NameIdentifier),
    LoginId = user.Identity!.Name
})).RequireAuthorization();
app.Run();

static string SelectCookies(string header) => string.Join("; ",
    header.Split(';', StringSplitOptions.TrimEntries)
        .Where(part =>
        {
            var separator = part.IndexOf('=');
            if (separator <= 0) return false;
            var name = part[..separator];
            if (name is ".AspNetCore.Cookies" or "Pleasanter_SessionGuid")
                return true;
            const string chunk = ".AspNetCore.CookiesC";
            return name.StartsWith(chunk, StringComparison.Ordinal)
                && name.Length > chunk.Length
                && name[chunk.Length..].All(char.IsAsciiDigit);
        }));

// プリザンターの ApiVersion 1.1 の応答に合わせた DTO。
record ApiResult(int StatusCode, ApiResponse? Response);
record ApiResponse(int TotalCount, ApiUser?[]? Data);
record ApiUser(int TenantId, int UserId, string? LoginId);

HTTPS の公開入口から確認する ​

powershell
dotnet run --no-launch-profile --urls http://127.0.0.1:5050

この例は、同じマシンのリバースプロキシで HTTPS を終端する構成です。ブラウザ向けの公開 URL と転送先を次のようにします。

公開 URL の例転送先の例
https://forms.example.com/myapp/ 以下http://127.0.0.1:5050/myapp/ 以下。接頭辞を保持
それ以外の / 以下http://127.0.0.1:8080/ 以下のプリザンター

プロキシから X-Forwarded-Proto: https を渡します。サンプルは既定のループバックのプロキシを信頼する構成です。別ホストのプロキシを使う場合は、そのアドレスだけを信頼するよう Forwarded Headers の設定を変更してください。セッションと CSRF 用 Cookie は Secure 属性付きなので、ブラウザからは HTTPS の公開 URL にアクセスします。

プリザンターにログインした同じブラウザで /myapp/login を開き、ボタンを押します。許可された利用者なら /myapp/me に移動し、本人の ID とログイン ID が返ります。未ログインなら 401、許可外なら 403、API の不正な応答や通信障害なら 503 になります。

/myapp はサンプルの配置パスです。別のパスなら appPath とプロキシの振り分けをそろえます。プリザンターが /pleasanter/ にある場合は、たとえば appPath を /pleasanter/myapp にし、BaseUrl も実際の内部配置に合わせます。

掲載コードは .NET SDK 10.0.401 でビルドし、疑似 API でセッション発行、許可外ユーザー・別テナントの拒否、Cookie の選別、CSRF トークンの検証、不正応答の拒否を確認しました。この掲載用サンプルを実機のプリザンターへ接続する試験は未実施です。

API 利用制限がある場合の代替経路 ​

通常の本人確認はユーザー取得 API の 1 回で済みます。利用者の API 利用制限で 403 になる場合は、セッション API で本人の ID を確認してから、サーバー側の API キーでユーザー情報を取得する構成も可能です。

この経路を採用する場合は、利用制限の運用方針に合うことを確認します。以下は確認したバージョンの挙動を使う方法であり、すべてのエラーに対する自動的な回避策にはしません。

図を読み込み中…

まず、利用者の Cookie で POST {ベース URL}/api/sessions/set を呼びます。本文のキー名と値は例示です。

json
{
  "ApiVersion": 1.1,
  "SessionKey": "MyApp.SsoProbe",
  "SessionValue": "1790290800"
}

この API は認証済みの Cookie を確認し、セッションへの保存結果とともに UserId を返します。代替経路は読み取りだけではなく、小さなセッション値の書き込みを伴います。 アプリ固有の固定キーを更新し、SavePerUser は指定しない構成にします。

応答が成功し、正の整数の UserId を取得できたら、次に POST {ベース URL}/api/users/{UserId}/get を呼びます。

json
{
  "ApiVersion": 1.1,
  "ApiKey": "サーバーだけで保持するAPIキー"
}

この要求に利用者の Cookie は付けません。API キーは対象ユーザーを取得できる同じテナントのものを使い、接続先は本人確認と同じプリザンターに固定します。応答を通常経路と同じ基準で検証し、さらに最初に得た UserId と一致することを確認します。この説明では一つの外部アプリの接続先を一つのプリザンター・テナントに固定します。

Cookie の要求の 401 は未ログイン、API キーの要求の 401・403 は接続設定や権限の問題として区別します。キーがない場合や所属テナントが違う場合にログインを成立させず、一連の要求全体にタイムアウトを設けます。

根拠: sessions/set の認証判定、保存と UserId の応答、ユーザー取得のテナント条件。

本人確認の後、外部アプリで利用許可を決める ​

プリザンターから本人情報が返ったら、外部アプリの利用者と対応付け、利用可否と役割を判定します。既存アカウントの LoginId と照合する方法や、プリザンターの接続先・TenantId・UserId を外部 ID として保持する方法があります。LoginId を使う場合は、変更や再利用時の対応を決めておきます。

未登録者を拒否するか、条件を満たす人だけ自動登録(JIT)するかは外部アプリで決めます。プリザンターの管理者権限を自動的に引き継がず、登録時の役割と管理者の付与手続きを明示します。組織・グループによる制限を使う場合も、この段階で判定します。

外部アプリ側でも 2 要素認証が必要なら、所属・権限の判定後にその手続きを完了してからセッションを発行します。SSO であることだけを理由に、外部アプリの認証要件を省略しない設計にします。

応用:組織・グループで利用できる人を絞る ​

「営業部の人だけ」「外部アプリ利用者グループのメンバーだけ」といった制限も、本人確認の後に所属を判定することで実装できます。プリザンターで所属を管理し、外部アプリが利用を許可するか決める構成です。

ここでは、本人確認の処理に所属情報の取得と認可を組み合わせる設計例を紹介します。API のデータ形式とフィルターの処理はソースで確認しています。

図を読み込み中…

所属の判定は、アプリ利用者の自動作成(JIT)やセッション発行より前に、サーバー側で実行します。ブラウザから渡された DeptId や GroupId をそのまま採用せず、Cookie で確認できた本人の TenantId・UserId に結び付く情報を取得します。

View に条件を追加して、許可された本人だけを取得する ​

POST {プリザンターのベース URL}/api/users/get の View.ColumnFilterHash に、UserId: Own と所属条件を一緒に指定します。本人であることと、利用を許可する条件を満たすことを、一つの問い合わせで確認できます。 この要求は利用者の Cookie で呼び、API キーを付けません。

組織 ID 10 または 20 の利用者だけに許可する例です。

json
{
  "ApiVersion": 1.1,
  "View": {
    "ColumnFilterHash": {
      "UserId": "[\"Own\"]",
      "DeptId": "[10,20]"
    }
  }
}

グループ ID 30 または 40 のメンバーだけに許可する場合は、Groups を使います。ユーザー取得 API のグループ条件のキーは GroupId ではなく Groups です。

json
{
  "ApiVersion": 1.1,
  "View": {
    "ColumnFilterHash": {
      "UserId": "[\"Own\"]",
      "Groups": "[30,40]"
    }
  }
}

ColumnFilterHash の値は、配列そのものではなく 配列を表す JSON 文字列です。同じキーの配列内は「いずれか」、異なるキーは AND 条件になります。Own を外すと本人以外も検索対象になるため、必ず残します。

指定する条件意味
UserId と DeptId本人、かつ指定組織のいずれか
UserId と Groups本人、かつ指定グループのいずれか
UserId・DeptId・Groups本人、かつ指定組織、かつ指定グループ

確認した実装の Groups 条件は、ユーザーの登録と所属組織経由の登録を調べ、無効なグループを除外します。組織経由の判定では無効な組織も除外します。外部アプリでメンバー一覧を独自に解析するより、この条件に所属の判定を任せられます。根拠は View の条件処理 です。

C# サンプルの要求を差し替える ​

前の Program.cs の request.Content = JsonContent.Create(...) を、次に置き換えます。この例は「組織 10 または 20 に所属し、かつグループ 30 または 40 のメンバー」という条件です。片方だけで制限する場合は、不要なキーを削除します。

csharp
var filters = new Dictionary<string, string>
{
    ["UserId"] = "[\"Own\"]",
    ["DeptId"] = JsonSerializer.Serialize(new[] { 10, 20 }),
    ["Groups"] = JsonSerializer.Serialize(new[] { 30, 40 })
};
request.Content = JsonContent.Create(new
{
    ApiVersion = 1.1,
    View = new { ColumnFilterHash = filters }
}, options: new JsonSerializerOptions { PropertyNamingPolicy = null });

ID はサーバー側の設定として管理し、ブラウザから自由に指定させないようにします。フィルターを使った取得では、成功応答の0件は、本人が所属条件を満たさない場合にも返ります。 Program.cs の result?.StatusCode != 200 から始まる件数チェックも、次のように置き換えます。直前の 401・403 の判定は残します。

csharp
if (result?.StatusCode != 200 || result.Response is null
    || result.Response.Data is null)
    return Results.StatusCode(503);

if (result.Response.TotalCount == 0 && result.Response.Data.Length == 0)
    return Results.StatusCode(403);

if (result.Response.TotalCount != 1 || result.Response.Data.Length != 1)
    return Results.StatusCode(503);

その後の識別子・テナントの検証は残します。サンプルの AllowedUserIds も残すなら「所属条件と個別の許可リストの両方」を満たす人だけが通ります。個別の許可リストを使わず所属条件だけで許可する場合は、認可部分を次に置き換えます。

csharp
if (user.TenantId != tenantId)
    return Results.StatusCode(403);

組織またはグループのどちらかで許可する ​

「組織 10・20 の人、または例外承認グループ 30 の人」のように OR 条件にする場合は、or_ で始まるキーに条件をまとめます。Own は OR の外に置き、必ず本人だけを検索します。次は C# の filters を置き換える例です。

csharp
var filters = new Dictionary<string, string>
{
    ["UserId"] = "[\"Own\"]",
    ["or_Access"] = JsonSerializer.Serialize(new Dictionary<string, string>
    {
        ["DeptId"] = JsonSerializer.Serialize(new[] { 10, 20 }),
        ["Groups"] = JsonSerializer.Serialize(new[] { 30 })
    })
};

ここでの判定は 本人 AND (許可組織 OR 許可グループ) です。Own を OR の中に置くと、本人なら所属条件を問わず通る式になってしまいます。

API キーを使う代替経路にも同じ条件を適用する ​

代替経路の POST /api/users/{UserId}/get にも、同じ DeptId・Groups の条件を付けます。ただし、**この要求の本人は URL に指定する、Cookie から確認済みの UserId**です。API キーの利用者を表してしまう Own は使わず、ColumnFilterHash に UserId も重複指定しません。

たとえば Cookie で確認した本人が 123 なら、POST /api/users/123/get に次の本文を送ります。0件なら許可せず、1件なら応答の UserId が 123 と一致することを確認します。

json
{
  "ApiVersion": 1.1,
  "ApiKey": "サーバーだけで保持するAPIキー",
  "View": {
    "ColumnFilterHash": {
      "DeptId": "[10,20]",
      "Groups": "[30,40]"
    }
  }
}

ログイン後の再検証でも、初回と同じ所属フィルターを付けます。異動やグループ脱退によって0件になったら、外部アプリのセッションを失効させます。

取得した情報を使って外部アプリ側で判定する場合 ​

View で所属を絞る方法に加え、取得した情報をアプリ独自の認可に使う方法もあります。以下はその場合の扱いです。

組織で絞る:本人の DeptId を許可リストと照合する ​

ユーザー取得 API の応答には、所属組織を表す DeptId があります。たとえば許可する組織 ID を 10 と 20 に決め、本人の DeptId がどちらかに一致した場合だけ通します。組織名ではなく、想定するテナントと組織 ID の組み合わせで管理します。ユーザーのデータレイアウト

通常経路では、すでに本人確認で取得したユーザー応答から DeptId も読み取るよう拡張できます。API 利用禁止時の代替経路でも、本人の UserId を確定した後に取得するユーザー応答から同じ項目を読み取り、両経路に同じ認可処理を適用します。値の欠落や不正な型を、許可扱いにしないことが必要です。

この方法は DeptId の完全一致です。親組織を許可リストに入れただけで、下位組織も自動的に許可する設計にはしていません。対象にする組織 ID を明示してください。

グループで絞る:許可用グループのメンバーと照合する ​

プリザンターに「外部アプリ利用者」のようなグループを用意し、そのグループ ID を外部アプリのサーバー設定で指定します。たとえば ID 30 なら、サーバーから POST {プリザンターのベース URL}/api/groups/30/get を呼びます。ベース URL に /pleasanter/ がある場合は、そのパスも含めます。

json
{
  "ApiVersion": 1.1,
  "ApiKey": "サーバーだけで保持するAPIキー"
}

この取得は、本人確認が済んだ後の所属照合です。API キーで取得できたこと自体を、ブラウザの利用者の本人確認に置き換えてはいけません。問い合わせ先は本人確認と同じプリザンター・テナントに固定し、この例では利用者の Cookie を混ぜず、API の取得権限を持つ接続用ユーザーのキーで呼びます。

HTTP と応答内の StatusCode、取得件数、TenantId・GroupId、Disabled を確認してから、返された GroupMembers を読みます。次は判定に使う項目だけを抜き出した説明用の例です。グループ取得 API

json
{
  "TenantId": 1,
  "GroupId": 30,
  "Disabled": false,
  "GroupMembers": ["User,123,False", "Dept,10,False"],
  "GroupChildren": []
}
メンバー情報の例この応用例での判定
User,123,False本人の UserId が 123 なら所属と判定
Dept,10,False本人の DeptId が 10 なら所属と判定
末尾の True / Falseグループの管理者権限。False もメンバーであり、所属無効の意味ではない

文字列の部分一致ではなく、種別・ID・管理者フラグを分けて解析します。グループの管理者フラグを外部アプリの管理者権限へ自動変換せず、アプリ内の権限は別に決めます。メンバー情報の形式

この例はユーザー・組織の直接登録を対象にします。 子グループは GroupChildren に別で返されるため、GroupMembers だけでは子グループ経由の所属を判定できません。まずは子グループを持たない許可用グループで運用する方法が明確です。子グループも対象にするなら、子を取得して所属をたどる処理と、循環・深さ・件数の上限、無効グループ・取得失敗の扱いを追加し、別途検証します。未対応の構成を見つけた場合は設定エラーとして扱います。

条件の組み合わせと、所属変更の反映 ​

組織とグループを併用するときは、AND / OR を明示します。以下の ID はすべて説明用です。

利用条件判定の例
指定組織だけDeptId が 10 または 20
指定グループだけグループ 30 のメンバー
指定組織のうち、利用申請済みの人だけ許可組織への所属 AND グループ 30 への所属
指定組織の人に加え、例外承認された人も許可組織への所属 OR グループ 30 への所属

ログイン時だけ判定すると、異動やグループ脱退後も既存セッションで利用できてしまいます。後述のセッション再検証に所属の再取得・再判定も追加し、対象外になったらアプリのセッションを失効させます。許可リストを変更した場合も同様です。本人が同じかどうかの確認に加え、所属条件も再判定することが必要です。

認証済みでも所属条件を満たさない場合は、利用不可として 403 を返す設計にします。API のタイムアウトや不正な応答で所属を確認できない場合は 503 などの障害として扱い、その要求を通しません。キャッシュを使う場合は、再検証間隔とキャッシュの有効期間を含めて、所属変更の反映までに許容する時間を決めます。

実装後は、許可組織・対象外組織、ユーザーの直接登録・組織経由の登録、メンバーの False、無効グループ、異なるテナント、API 障害、ログイン後の異動・脱退を確認します。通常の本人確認経路と API 利用禁止時の代替経路の両方で、同じ制限が働くことも確認してください。

項目と取得形式は、Pleasanter 1.5.8.1 の UserApiModel、GroupApiModel、グループ取得処理 で確認しています(参照日:2026-09-25)。

セッションの再検証とログアウト ​

外部アプリのセッションには、確認したプリザンターの接続先・TenantId・UserId・確認時刻を記録します。独自の有効期限を設けるとともに、一定時間後の保護された要求でプリザンターへ再問い合わせします。たとえば「前回の確認から 5 分を過ぎた最初の要求で再検証」という設計です。5 分は例であり、必要な失効速度と負荷に合わせて決めます。

再検証結果外部アプリで実装する処理の例
同じ TenantId・UserId で、利用条件も満たすセッションを継続
未ログイン、または別の利用者セッションを失効し、再ログインを要求
組織・グループの許可条件を満たさなくなったセッションを失効し、利用を拒否
タイムアウト・不正な応答保護された処理を通さず、障害として返す

プリザンターからログアウトしても、外部アプリのセッションが自動的に消えるとは限りません。これは標準のシングルログアウト通知を使う方式ではないため、再検証で失効を検知する必要があります。

外部アプリだけからログアウトする場合は、外部アプリのセッションを破棄します。プリザンターへのログイン状態が残っていると、直後の自動 SSO で入り直してしまうため、明示的にログインボタンを押すまで自動ログインを抑止するなどの扱いを決めます。両方からログアウトさせる場合は、外部アプリのセッションを破棄した後にプリザンターのログアウト画面へ案内します。

外部アプリの Cookie の Path は、自分の公開パスに絞ります。たとえば Path /pleasanter/myapp の Cookie は、/pleasanter/users/logout には送られません。プリザンター側の Cookie 削除に依存せず、自分のセッションを管理します。

実装後に確かめること ​

確認する操作・状況期待する結果
プリザンターにログインして外部アプリへアクセスCookie の本人としてログインできる
プリザンターに未ログインセッションを発行せず、ログインへ案内する
プリザンターの 2 要素認証の完了前・完了後完了前は通らず、完了後に本人を確認できる
ブラウザから別人の ID を送る採用せず、Cookie の本人で判定する
未許可の利用者や組織・グループアプリのセッションを発行しない
プリザンターでログアウト・別人へ切り替え再検証後、元のセッションで処理を続けない
所属変更・グループ脱退設計した再検証間隔で利用を拒否する
API の障害・HTML 応答・不正な JSON認証成功として扱わない
異なる利用者から並行してアクセスHTTP クライアント内で Cookie が混ざらない

接続できないときは、最初にブラウザから外部アプリへ Cookie が届くかを確認し、次にサーバーからプリザンターの API へ届くかを確認します。前者は公開ホスト名・Cookie の Path・Secure 属性、後者は内部 URL・転送対象の Cookie 名・IP 制限・API の前提設定を調べます。確認用のログにも Cookie や API キーの値は残しません。

関連ページ ​

変更履歴

第1版外部アプリの SSO 解説を追加し、外部連携の記事一覧を整理