ユースケース — 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 と引き換えます。このライブラリはどちらの役も、両方の役も担えます。
リソース側が使う grant は、先に JWT bearer grant を読んでください。このページは ID-JAG が加えるものを扱います。
IdP 側
op.WithIDJAGIssuance を指定すると、OP は ID-JAG を求める token-exchange リクエストに応答するようになります。呼び出し側はこの OP が発行した ID Token を提示し、assertion を発行するかどうか、何について発行するかを op.IDJAGIssuancePolicy が判断します。
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 で拒否されます。
リクエストとレスポンス
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/tokenRFC 8693 では発行した token がどの種類でも access_token メンバーに入るので、assertion はそこに載ります。
{
"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.ErrIDJAGUnknownAudience | invalid_target |
op.ErrIDJAGDenied、または (nil, nil) | access_denied |
その他のエラー、空の ClientID、リクエストにない Resource | server_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 です。
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 header | oauth-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 を記録します。監査イベントカタログ を参照してください。
次に読む
- JWT bearer grant: リソース側が使う grant。通常プロファイルも含みます。
- トークン交換: IdP 側が拡張する grant。
- 設計判断: ID-JAG が OP の単一の署名アルゴリズムに従う理由。