Skip to content

ユースケース — Identity Assertion JWT Authorization Grant(ID-JAG) ​

Experimental

ID-JAG は IETF draft(draft-ietf-oauth-identity-assertion-authz-grant-06)に追従しています。このページで扱う API はすべてライブラリの godoc で Experimental と明記され、api/experimental.txt に載っています。draft が通信路上の形式を変えた場合、マイナーリリースで変更されることがあり、SemVer の保証対象外です。利用する場合はライブラリのバージョンを固定してください。

ユーザが自社の IdP でアプリケーションにサインインしたあと、そのアプリケーションが別の組織の API をユーザの代わりに呼びたいことがあります。2 回目の同意画面は出したくありません。ID-JAG は、すでにある 2 つの grant をつなぐことでこれを実現します。IdP が短命の署名付き assertion(ID-JAG)でユーザを保証し、相手組織の認可サーバがそれを access token と引き換えます。このライブラリはどちらの役も、両方の役も担えます。

IdP が保証し、リソース側が引き換える
クライアントは IdP 発行の ID トークンを持っています。IdP の token endpoint で ID トークンを ID-JAG に交換し、その ID-JAG をリソース認可サーバの token endpoint に jwt-bearer grant で提示して access token を受け取ります。クライアントID トークンを保持IdPID-JAG を発行リソース ASID-JAG を引き換え1POST /tokentoken-exchange, id_token2ID-JAGN_A、有効期間は最大 5 分3POST /tokenjwt-bearer, assertion=ID-JAG4access tokenrefresh token なし
2 つのサーバが直接やり取りすることはありません。リソース認可サーバは IdP の署名鍵を信頼し、assertion はクライアントが運びます。手順 3 は通常の jwt-bearer grant に、プロファイルを 1 つ足したものです。

リソース側が使う grant は、先に JWT bearer grant を読んでください。このページは ID-JAG が加えるものを扱います。

IdP 側 ​

op.WithIDJAGIssuance を指定すると、OP は ID-JAG を求める token-exchange リクエストに応答するようになります。呼び出し側はこの OP が発行した ID Token を提示し、assertion を発行するかどうか、何について発行するかを op.IDJAGIssuancePolicy が判断します。

go
import (
  "context"
  "time"

  "github.com/libraz/go-oidc-provider/op"
  "github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)

type idjagPolicy struct{}

func (idjagPolicy) Allow(ctx context.Context, req op.IDJAGRequest) (*op.IDJAGDecision, error) {
  // 関係のある認可サーバ宛てにだけ発行する。
  if req.Audience != "https://as.partner.example" {
    return nil, op.ErrIDJAGUnknownAudience
  }
  if req.Client.ID != "agent-portal" {
    return nil, op.ErrIDJAGDenied
  }
  return &op.IDJAGDecision{
    // このクライアントの、パートナー側サーバでの identifier(自社側のものではない)。
    ClientID: "acme-agent-portal",
    Scope:    req.Scope,
    Resource: req.Resource,
    TTL:      2 * time.Minute,
  }, nil
}

st := inmem.New()

provider, err := op.New(
  op.WithIssuer("https://idp.example.com"),
  op.WithStore(st),
  op.WithUserStore(st.Users()),
  op.WithKeyset(myKeyset),
  op.WithCookieKeys(myCookieKey),
  // authorization_code grant にはログインフローが必要。
  op.WithLoginFlow(op.LoginFlow{Primary: op.PrimaryPassword{Store: st.UserPasswords()}}),
  // 後述の「相互運用」を参照: draft の必須アルゴリズムは RS256。
  op.WithSigningAlgorithm(op.AlgRS256),
  op.WithIDJAGIssuance(idjagPolicy{}),

  op.WithStaticClients(op.ConfidentialClient{
    ID:           "agent-portal",
    Secret:       portalSecret,
    AuthMethod:   op.AuthClientSecretBasic,
    RedirectURIs: []string{"https://portal.example.com/callback"},
    GrantTypes:   []string{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange"},
    Scopes:       []string{"openid"},
  }),
)

op.WithIDJAGIssuance は token-exchange の grant type を単独で有効にするので、op.WithTokenExchangeGrant は不要です。policy は必須で nil は不可、オプションは 1 回だけ指定できます。このオプションなしで ID-JAG の token type を要求する token-exchange リクエストは、invalid_request で拒否されます。

リクエストとレスポンス ​

sh
curl -s \
  -u agent-portal:<secret> \
  -d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
  -d "subject_token=$ID_TOKEN" \
  -d 'subject_token_type=urn:ietf:params:oauth:token-type:id_token' \
  -d 'requested_token_type=urn:ietf:params:oauth:token-type:id-jag' \
  -d 'audience=https://as.partner.example' \
  -d 'scope=docs:read' \
  -d 'resource=https://api.partner.example' \
  https://idp.example.com/oidc/token

RFC 8693 では発行した token がどの種類でも access_token メンバーに入るので、assertion はそこに載ります。

json
{
  "access_token": "<the ID-JAG>",
  "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag",
  "token_type": "N_A",
  "expires_in": 120,
  "scope": "docs:read"
}

ID-JAG は access token ではないため、リクエストに DPoP proof があっても token_type は N_A です。refresh token は発行されません。assertion の JOSE typ は oauth-id-jag+jwt で、OP の署名アルゴリズムで署名されます。claim は iss、sub、aud(要求された audience)、client_id(policy の ClientID)、jti、iat、exp に加え、該当すれば scope、resource、email、auth_time、acr、amr、cnf です。cnf は交換リクエストの DPoP proof(jkt)またはクライアント証明書(x5t#S256)に従い、方式は 1 つだけです。

policy の前に OP が強制すること ​

  • クライアントは認証済みであること。public クライアントは invalid_client で拒否されます。
  • subject_token_type は id_token の URN で、token は呼び出したクライアント宛てにこの OP が発行した ID Token であり、その grant が有効であること。同意が撤回されていれば交換は閉じます。
  • audience はちょうど 1 つ。actor_token と actor_token_type は invalid_request で拒否されます。
  • pairwise subject に登録されたクライアントは ID-JAG を取得できません。grant レコードを公開 sub と突き合わせられないためです。

scope、audience、resource は 相手側 サーバの名前空間のものなので、OP は呼び出したクライアントの登録内容とは照合しません。これらを制限できるのは policy だけです。

decision とエラー ​

policy の戻り値応答
*op.IDJAGDecision発行。ClientID は必須。Resource の各値は、リクエストが指定した値そのままであること。
op.ErrIDJAGUnknownAudienceinvalid_target
op.ErrIDJAGDenied、または (nil, nil)access_denied
その他のエラー、空の ClientID、リクエストにない Resourceserver_error
リクエストが resource を指定したのに Resource が空invalid_target

IDJAGDecision.TTL が 0 または 5 分超のときは 5 分になります。発行は audit.TokenExchangeIDJAGIssued(token_exchange.idjag_issued)として記録され、extras に audience、aud_client_id、granted_scope、resource、ttl_seconds、assertion の jti が入ります。

リソース認可サーバ側 ​

引き換える側は、issuer の Profile を op.AssertionProfileIDJAG にした JWT bearer grant です。

go
st := inmem.New()

provider, err := op.New(
  op.WithIssuer("https://as.partner.example"),
  op.WithStore(st),
  op.WithUserStore(st.Users()),
  op.WithKeyset(myKeyset),
  op.WithCookieKeys(myCookieKey),
  // ここではエンドユーザがサインインしないため、既定の authorization_code grant を外す。
  op.WithGrants(grant.ClientCredentials),

  op.WithJWTBearerGrant(op.JWTBearerGrant{
    Issuers: []op.AssertionIssuer{{
      Issuer:  "https://idp.example.com",
      JWKSURI: "https://idp.example.com/jwks",
      Profile: op.AssertionProfileIDJAG,
      // IdP の policy が client_id に書き込む identifier。
      AllowedClients: []string{"acme-agent-portal"},
    }},
    ResolveSubject: func(ctx context.Context, a op.AssertionSubject) (string, error) {
      // RFC 7523 にない claim(email など)は a.Claims から読む。
      email, _ := a.Claims["email"].(string)
      local, ok := lookupAccountByEmail(ctx, email)
      if !ok {
        return "", op.ErrUnknownAssertionSubject
      }
      return local, nil
    },
  }),

  op.WithStaticClients(op.ConfidentialClient{
    ID:         "acme-agent-portal",
    Secret:     portalSecret,
    AuthMethod: op.AuthClientSecretBasic,
    GrantTypes: []string{"urn:ietf:params:oauth:grant-type:jwt-bearer"},
    Scopes:     []string{"docs:read"},
    Resources:  []string{"https://api.partner.example"},
  }),
)

クライアントは JWT bearer grant と同じ要領で、ID-JAG を assertion として引き換えます。通常の検証に加えて、ID-JAG プロファイルは次を要求します。

claimルール
typ headeroauth-id-jag+jwt。
audこの OP の issuer ちょうど 1 つ(文字列、または要素 1 つの配列)。通常プロファイルが受け付ける token endpoint の URL は受け付けません。
iat必須。
client_id認証されたクライアントと一致すること。
scope任意。要求した scope はすべてこの範囲内であること。発行される scope もこの範囲に絞られます。
resource任意。各値がクライアントの Resources に登録済みであること。リクエストの resource はその中の 1 つであること。指定がなければ、claim の値が 1 つならそれが audience になり、複数なら要求側が選ぶ必要があります(さもなくば invalid_target)。
cnf任意。あれば jkt か x5t#S256 のどちらか 1 つだけで、引き換え時の DPoP proof またはクライアント証明書と一致すること。
tenant、aud_tenant拒否します。マルチテナントには対応していません。

これらのどれに違反しても invalid_grant で、監査の reason は idjag です。ここでも jti は単回使用なので、新しい access token が必要なクライアントは IdP から新しい ID-JAG を取り直します。レプリカ間で ConsumedJTIStore を共有してください。

相互運用 ​

discovery。 op.WithIDJAGIssuance を使う OP は、identity_chaining_requested_token_types_supported に ID-JAG の token type URN を載せます。op.AssertionProfileIDJAG の issuer を 1 つ以上信頼する OP は、authorization_grant_profiles_supported に urn:ietf:params:oauth:grant-profile:id-jag を載せます。該当する issuer が設定されていなければ、プロファイルは公開されません。id_jag_signing_alg_values_supported は公開しません。

署名アルゴリズム。 draft は RS256 を必須実装としていますが、OP の既定は ES256 です。基本アルゴリズムしか実装しないリソース認可サーバは ES256 の ID-JAG を検証できないため、そうした相手に発行する IdP は op.WithSigningAlgorithm(op.AlgRS256) が必要です。ID-JAG 専用の設定はなく、このスイッチは OP が発行するすべての token を RS256 にします。理由は 設計判断 34 にあります。

監査。 IdP は audit.TokenExchangeIDJAGIssued を、リソース側は profile に ID-JAG プロファイルを入れた audit.JWTBearerGranted、または idjag などの reason を持つ audit.JWTBearerRejected を記録します。監査イベントカタログ を参照してください。

次に読む ​