Windows Server 2016 版 ADFS を触ってみた

MVP になったことですし、早速 Azure 常に Windows Server 2016 の VM 立ち上げて、次期バージョンの ADFS を動かしてみました。

想定ユースケースは、Native App とその Backend Server があって、Backend Server が Native App 向けに提供している API 用の Access Token も、ADFS が発行するというものです。

まさしく今後エンプラで増えていくであろうパターンですね。

Setup Hybrid Client on ADFS

ADFS Manager に “Application Groups” っていう設定が増えてるんで、そこから “Native Application and Web API” ってのを選択して、Connect RP (= OAuth Client) を登録します。

New Hybrid Client

スクショいっぱい撮るのが面倒なのですっ飛ばしますが、こんな感じで Native App とその Backend Server を ADFS に “Application Group” として登録しました。

Registered Hybrid Client

ちなみに、この時 Backend には特に Native App と別の client_id が発行されたりはしません。

ここまでで、ADFS 側の準備は完了です。以降、こちらの gist に沿って、Step by Step で見ていきます。

GET /.well-known/openid-configuration

まずは ADFS の OpenID Configuration を取得しましょう。

あいにく ADFS の Webfinger はまともに動いてる気配がしないので、issuer は https://<your-adfs-domain>/adfs をハードコードします。

1
2
3
config = OpenIDConnect::Discovery::Provider::Config.discover!(
  'https://sts.example.com/adfs'
) # GET https://sts.example.com/adfs/.well-known/openid-configuration

すると、こんな JSON が返ってきます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
{
  "issuer": "https://sts.example.com/adfs",
  "authorization_endpoint": "https://sts.example.com/adfs/oauth2/authorize/",
  "token_endpoint": "https://sts.example.com/adfs/oauth2/token/",
  "jwks_uri": "https://sts.example.com/adfs/discovery/keys",
  "token_endpoint_auth_methods_supported": [
    "client_secret_post",
    "client_secret_basic",
    "private_key_jwt",
    "windows_client_authentication"
  ],
  "response_types_supported": [
    "code",
    "id_token",
    "code id_token",
    "token id_token"
  ],
  "response_modes_supported": [
    "query",
    "fragment",
    "form_post"
  ],
  "grant_types_supported": [
    "authorization_code",
    "refresh_token",
    "client_credentials",
    "urn:ietf:params:oauth:grant-type:jwt-bearer",
    "implicit",
    "password",
    "srv_challenge"
  ],
  "subject_types_supported": ["pairwise"],
  "scopes_supported": [
    "aza",
    "vpn_cert",
    "user_impersonation",
    "openid",
    "profile",
    "admin-only",
    "email",
    "logon_cert"
  ],
  "id_token_signing_alg_values_supported": ["RS256"],
  "token_endpoint_auth_signing_alg_values_supported": ["RS256"],
  "access_token_issuer": "http://sts.example.com/adfs/services/trust",
  "claims_supported": [
    "aud",
    "iss",
    "iat",
    "exp",
    "auth_time",
    "nonce",
    "at_hash",
    "c_hash",
    "sub",
    "upn",
    "unique_name",
    "pwd_url",
    "pwd_exp"
  ],
  "microsoft_multi_refresh_token": true,
  "userinfo_endpoint": "https://sts.example.com/adfs/userinfo"
}

response_types_supported 見る限り、code token とか code token id_token はサポートされてないですね。なんででしょうね?

まぁ、気にせず進みましょう。

GET /oauth2/authorize

取得した OP Config をもとに、Authorization Request を発行します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
client = OpenIDConnect::Client.new(
  identifier: '2940bc16-983c-41ca-b373-4c6045278627', # Native App's client_id
  redirect_uri: 'custom-schema://foobar',
  authorization_endpoint: config.authorization_endpoint,
  token_endpoint: config.token_endpoint,
  userinfo_endpoint: config.userinfo_endpoint
)

authorization_uri = client.authorization_uri(
  scope: [:openid, :email, :profile]
)
# => https://sts.example.com/adfs/oauth2/authorize/?
#            client_id=2940bc16-983c-41ca-b373-4c6045278627&
#            redirect_uri=custom-schema%3A%2F%2Ffoobar&
#            response_type=code&
#            scope=openid+email+profile

この URL をブラウザで開くと、ADFS のログイン画面が表示されて、ログインしたら特に同意画面とかはなく custom-schema://foobar にリダイレクトして戻ってきます。

リダイレクト URL の Query には code がついてるので、そいつコピーします。

GET /oauth2/token

取得した code を使って、Access Token を取得します。

今回は Native App 向けに response_type=code を指定してるので、client_secret なしで Access Token が取得できます。(ID Token も同時に発行されます)

1
2
client.authorization_code = code
token = client.access_token!

取得した Access Token は JWT になっているので、Payload を見てみましょう。audurn:microsoft:userinfo になっています。 あと、Authorization Request では openid email profile を指定したはずなのに、ここでは scp (scopes) が openid だけになってますね。

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "aud": "urn:microsoft:userinfo",
  "iss": "http://sts.example.com/adfs/services/trust",
  "iat": 1460887152,
  "exp": 1460890752,
  "apptype": "Public",
  "appid": "2940bc16-983c-41ca-b373-4c6045278627",
  "authmethod": "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport",
  "auth_time": "2016-04-17T09:37:20.197Z",
  "ver": "1.0",
  "scp": "openid",
  "sub": "BsG1N+rcilM2dnBDB7kyha6YglKD3d9qcwbCixQEGlQ="
}

GET /userinfo

では UserInfo を取得します。

1
token.userinfo!

email profile の両 scope がどこかにいってしまったので、案の定 sub しか返ってきませんね。

1
2
3
{
  "sub": "BsG1N+rcilM2dnBDB7kyha6YglKD3d9qcwbCixQEGlQ="
}

sub だけなら Access Token にも入ってたし、UserInfo Endpoint いらねんじゃね?とか思いますが、まぁ Windows Server 2016 リリース時にはなんか変わってるかもですね。

GET /oauth2/token (for Backend API)

さて、ここまでは普通の OpenID Connect / OAuth2 のフローでしたが、いよいよ Native App の Backend API 用の Access Token を取得します。

先ほど Access Token & ID Token を取得した際に、Refresh Token も取得していたので、この Refresh Token を使って resource パラメータに ADFS に登録しておいた Backend API の Identifer を指定します。

1
2
3
4
client.refresh_token = token.refresh_token
token2 = client.access_token!(
  resource: 'http://backend.dev'
)

すると、また JWT-formatted な Access Token が発行されるので、Payload を確認します。

audhttp://backend.dev に変わっていますね。

1
2
3
4
5
6
7
8
9
10
11
{
  "aud": "http://backend.dev",
  "iss": "http://sts.example.com/adfs/services/trust",
  "iat": 1460886940,
  "exp": 1460890540,
  "apptype": "Public",
  "appid": "2940bc16-983c-41ca-b373-4c6045278627",
  "authmethod": "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport",
  "auth_time": "2016-04-17T09:37:20.197Z",
  "ver": "1.0"
}

Backend API Access

あとは、Backend API 側では iss=http://sts.example.com/adfs/services/trust かつ aud=http://backend.dev な Access Token を受け入れるだけですね。

Backend API 側では、OP Config の jwks_uri から JWT の署名検証用の公開鍵 (JWK-formatted) を取得して、Access Token の署名を検証するのを忘れずに。

1
2
3
4
5
6
7
8
9
10
11
12
13
// https://sts.example.com/adfs/discovery/keys
{
  "keys": [{
    "kty": "RSA",
    "use": "sig",
    "alg": "RS256",
    "kid": "B1B8yZ-HAp7g2iz8D6LO3X2gOeI",
    "x5t": "B1B8yZ-HAp7g2iz8D6LO3X2gOeI",
    "n": "1ag4fTO65qrnmUENL4K4ZK8fchrV7BbTNfaHIfwT1nFuIO-4C8lh3VkWykIlUwd0ANWwQsY75Xb5ZC8P67QnXexV8CoXu5jHp2lsHm4Fhq75QEpRql7OpamOKBFqPtWvBneWTZOCnr1SrWR9DjBWnfRhMKr-4Oleaqp--YCIBaIiVaJDx6YqLKmTn5UeFSfcV9LT4y6yjFqgNK2mS6epFnmree9mjVYCyRdCWJRWyrA1xVbSA2xmu6i9z7ZuNaEUFKyL3uTwArzKkipy0aDBSVRsKuhCMV8dFsVBm3EWmyHzsoneT-zGM2aG5EYF7oOUlx2lXafiCDCYQ0YqRRWddQ",
    "e": "AQAB",
    "x5c": ["MIIC1jCCAb6gAwIBAgIQFcgeY4EZ65RDuxjfwa+tXDANBgkqhkiG9w0BAQsFADAnMSUwIwYDVQQDExxBREZTIFNpZ25pbmcgLSBzdHMubWF0YWtlLmpwMB4XDTE2MDQxMjExNTU1MloXDTE3MDQxMjExNTU1MlowJzElMCMGA1UEAxMcQURGUyBTaWduaW5nIC0gc3RzLm1hdGFrZS5qcDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANWoOH0zuuaq55lBDS+CuGSvH3Ia1ewW0zX2hyH8E9ZxbiDvuAvJYd1ZFspCJVMHdADVsELGO+V2+WQvD+u0J13sVfAqF7uYx6dpbB5uBYau+UBKUapezqWpjigRaj7VrwZ3lk2Tgp69Uq1kfQ4wVp30YTCq\/uDpXmqqfvmAiAWiIlWiQ8emKiypk5+VHhUn3FfS0+MusoxaoDStpkunqRZ5q3nvZo1WAskXQliUVsqwNcVW0gNsZruovc+2bjWhFBSsi97k8AK8ypIqctGgwUlUbCroQjFfHRbFQZtxFpsh87KJ3k\/sxjNmhuRGBe6DlJcdpV2n4ggwmENGKkUVnXUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAK0bCMCgF3hfRnV5zwtO8Wzd5XBMkFVQmipOizv\/Llznn12jpe4mk1fKHqp1EuTtbEFQ93YGs9yaJb3bpU9MsSqKaTbcK269NgG\/v1ke1KcGFjL\/BkxABY5pRWG2U0Rpw1SL16DXoZj0gLT91A8gTikm8wTE8mqKBNarC6runStu8qw08iQqadkbg8zU+o7vLduyZlHTOO1sdXpKOcU1O+wirPz0tMtKfaXznF81LMhWhRxwRNOHqju8Vc5a1gcGG9Sj9lS0LaD+k7ybtiHx4DhiZ0tQP14C+OWPZDH1vtudkf6UpyBasxB4kVuOHDTB7sjPnOTqA4zKiKrHekW2jUg=="]
  }]
}

この Backend API 用の scope をどう扱えばいいのかとかは、またそのうち調べて見るかもしれません。

どうせ「同意」とかいう概念がない IdP なんだから、Backend API 用の scope も形骸化しそうな気はするけど。

Comments