OpenID Connect:不同身份提供者之间的刷新令牌行为不一致

OpenID Connect: inconsistent refresh token behaviour between different Identity Providers

我正在实施一个服务提供者,目前观察到不同身份提供者在获取刷新令牌方面的行为不一致。我将在底部附上我的服务提供商 golang 代码,以防它可以帮助某人或澄清我的问题。

我正在执行 authorization_code 流程,方法是使用查询参数 access_type=offline 将登录请求重定向到 */authn 端点。然后,第二步是在回调端点上接收授权代码,然后调用 */token 端点以交换代码以获取访问和刷新令牌。

我已经用 3 个不同的身份提供者尝试了这个流程,发现了以下结果:

  1. OneLogin (https://openid-connect.onelogin.com/oidc): 添加查询参数 access_type=offline 即可接收刷新令牌。
  2. Okta (https://my-company.okta.com):添加 access_type=offline 是不够的。我需要在第一步 (authn) 中将 offline_access 添加到请求的 Scopes 参数中。此配置也适用于 OneLogin!
  3. Google (https://accounts.google.com):然而,对于 Google,范围 offline_access 不是支持并返回 400 BAD REQUEST:

    Some requested scopes were invalid. {valid=[openid, https://www.googleapis.com/auth/userinfo.profile, https://www.googleapis.com/auth/userinfo.email], invalid=[offline_access]}

    唯一对 Google 起作用的是从 Scopes 中删除 offline_access 并添加值为 consent 的查询参数 prompt。但是,这不适用于 Okta 或 OneLogin...

我是不是遗漏了什么,或者我应该为每个 IdP 提供自定义授权流程实现,以支持刷新令牌?

看起来很奇怪,考虑到协议是完全指定的。

package openidconnect

import (
    "context"
    "encoding/json"
    "net/http"
    "os"

    oidc "github.com/coreos/go-oidc"
    "golang.org/x/oauth2"
)
var oidcClientID = getEnv("****", "OIDC_CLIENT_ID")
var oidcClientSecret = getEnv("****", "OIDC_CLIENT_SECRET")
var oidcProvider = getEnv("****", "OIDC_PROVIDER")

var oidcLoginURI = "/v1/oidc_login"
var oidcCallbackURI = "/v1/oidc_callback"
var hostname = getEnv("http://localhost:8080", "HOSTNAME")

func getEnv(defaultValue, key string) string {
    val := os.Getenv(key)
    if val == "" {
        return defaultValue
    }
    return val
}

//InitOpenIDConnect initiates open ID connect SSO
func InitOpenIDConnect() error {
    ctx := context.Background()

    provider, err := oidc.NewProvider(ctx, oidcProvider)
    if err != nil {
        return err
    }

    // Configure an OpenID Connect aware OAuth2 client.
    oidcConfig := oauth2.Config{
        ClientID:     oidcClientID,
        ClientSecret: oidcClientSecret,
        RedirectURL:  hostname + oidcCallbackURI,

        // Discovery returns the OAuth2 endpoints.
        Endpoint: provider.Endpoint(),

        // "openid" is a required scope for OpenID Connect flows.

        Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
        // TODO: For Okta and OneLogin, add oidc.ScopeOfflineAccess Scope for refresh token.
        // Removed for now because Google API returns 400 when it is set.
    }

    handleOIDCLogin(&oidcConfig)
    handleOIDCCallback(provider, &oidcConfig)

    return nil
}

var approvalPromptOption = oauth2.SetAuthURLParam("prompt", "consent")

func handleOIDCLogin(config *oauth2.Config) {
    state := "foobar" // Don't do this in production.

    http.HandleFunc(oidcLoginURI, func(w http.ResponseWriter, r *http.Request) {
        // approval prompt option is required for getting refresh token from Google API
        redirectURL := config.AuthCodeURL(state, oauth2.AccessTypeOffline, approvalPromptOption)
        http.Redirect(w, r, redirectURL, http.StatusFound)
    })
}

func handleOIDCCallback(provider *oidc.Provider, config *oauth2.Config) {
    state := "foobar" // Don't do this in production.
    ctx := context.Background()

    http.HandleFunc(oidcCallbackURI, func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Query().Get("state") != state {
            http.Error(w, "state did not match", http.StatusBadRequest)
            return
        }

        code := r.URL.Query().Get("code")

        oauth2Token, err := config.Exchange(ctx, code)
        if err != nil {
            http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
            return
        }

        tokenSource := config.TokenSource(ctx, oauth2Token)
        refreshedToken, err := tokenSource.Token()
        if err != nil {
            http.Error(w, "Failed to get refresh token: "+err.Error(), http.StatusInternalServerError)
            return
        }

        userInfo, err := provider.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token))
        if err != nil {
            http.Error(w, "Failed to get userinfo: "+err.Error(), http.StatusInternalServerError)
            return
        }

        resp := struct {
            OAuth2Token *oauth2.Token
            UserInfo    *oidc.UserInfo
        }{oauth2Token, userInfo}
        data, err := json.MarshalIndent(resp, "", "    ")
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        w.Write(data)
    })
}

不幸的是,我认为不同的提供商对这部分的实现方式不同。 Okta 似乎是其中最合规的(需要 offline_access,因为范围是 OIDC specification 所描述的。

使范围值可配置,并且可能还可以配置自定义参数(例如 access_type 参数)将是避免为每个提供程序完全自定义实现的一种方法。

prompt 参数是规范的一部分,因此无论如何使该参数可配置可能是个好主意。

这类问题确实很常见。抽象身份验证管道 - 我使用 'authenticator' 接口或基础 class,然后在需要的地方专门化。只要管道与您的宝贵逻辑分开,我发现它就可以正常工作。