OpenID Connect 协议(OIDC 协议)

Dec 10, 2025 · 7 min read

OAuth是一个关于授权(authorization)的开放网络标准.

OpenID Connect 是在OAuth2.0 协议基础上增加了身份验证层 (identity layer)。 OAuth 2.0 定义了通过access token去获取请求资源的机制,但是没有定义提供用户身份信息的标准方法。 OpenID Connect作为OAuth2.0的扩展,实现了Authentication的流程。OpenID Connect根据用户的 id_token 来验证用户,并获取用户的基本信息。

而 OIDC 的登录过程与 OAuth 相比,最主要的扩展就是提供了 ID Token. id_token通常是JWT(Json Web Token),JWT有三部分组成,header,body,signature。

授权方式

OAuth 2.0定义了四种授权方式。

  • 授权码模式(authorization code): 适用于拥有服务器端能力的 Web 应用。用户授权后,客户端获取授权码,再通过后台请求换取访问令牌,避免暴露敏感信息
  • 简化模式(implicit): 用于纯前端应用(如 SPA),直接返回令牌,但安全性较低,不推荐高敏感场景。
  • 密码模式(resource owner password credentials): 用户直接提供用户名和密码换取令牌,仅适用于高度信任的客户端,如自有客户端与自有服务。
  • 客户端模式(client credentials),也叫应用授信模式: 适用于服务间通信,无用户参与,使用客户端自身凭证获取访问权限。

授权码模式(authorization code)

  1. 用户访问客户端,客户端将用户重定向到认证服务器;(我需要访问这个用户在你的服务器上的数据!)
  2. 你的服务器询问用户是否同意授权,要求用户输入用户名和密码,并弹出对方请求获取的信息条目(好的,我先问问用户是否同意你获取这些信息)
  3. 返回一个授权码给三方应用前端(或后端)。(把这个授权码给你的后端,让他凭此来获取 token!)
  4. 三方应用后端携带这个授权码向你服务器的 token 颁发接口请求数据。(请给我一个 token,授权码是 xxx)
  5. 返回 id_token, access_token。(好的,这是你的 token,可以携带 access_token 去用户信息接口获取数据)

OAuth 中心组件

1 OAuth Scopes

Scopes即Authorization时的一些请求权限,即与access token绑定在一起的一组权限。

2 OAuth Tokens

Token从Authorization server上的不同的endpoint获取。主要两个endpoint为authorize endpoint和token endpoint.

authorize endpoint主要用来获得来自用户的许可和授权(consent and authorization),并将用户的授权信息传递给token endpoint。 token endpoint对用户的授权信息,处理之后返回access token和refresh token

3 OAuth Actors

有一个"云冲印"的网站,可以将用户储存在Google的照片,冲印出来。用户为了使用该服务,必须让"云冲印"读取自己储存在Google上的照片

(1)Third-party application:第三方应用程序,本文中又称"客户端"(client),即例子中的"云冲印"。

(2)HTTP service:HTTP服务提供商,本文中简称"服务提供商",即上一节例子中的Google。

(3)Resource Owner:资源所有者,本文中又称"用户"(user)。

(4)User Agent:用户代理,本文中就是指浏览器。

(5)Authorization server:认证服务器,即服务提供商专门用来处理认证的服务器。

(6)Resource server:资源服务器,即服务提供商存放用户生成的资源的服务器。它与认证服务器,可以是同一台服务器,也可以是不同的服务器。

OIDC provider

  • github.com/keycloak/keycloak:企业级协议强者(SAML/OAuth/LDAP),适用于需要细粒度访问控制及自建部署的大型组织。

  • github.com/casdoor/casdoor:以 Web UI 为中心的 IAM 与 SSO 平台,支持 OAuth 2.0、OIDC、SAML、CAS、LDAP 和 SCIM。

  • github.com/dexidp/dex

keycloak

Keycloak实现了业内常见的认证授权协议和通用的安全技术,主要有:

  • 浏览器应用程序的单点登录(SSO)。
  • OIDC认证授权。
  • OAuth 2.0。
  • SAML。

OpenID Provider 元数据

(|kind-cilium-cluster:nacos)➜  ~ curl -s http://keycloak.keycloak:8080/realms/myrealm/.well-known/openid-configuration  | jq .
{
  "issuer": "http://keycloak.keycloak:8080/realms/myrealm",
  "authorization_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/auth",
  "token_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/token",
  "introspection_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/token/introspect",
  "userinfo_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/userinfo",
  "end_session_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/logout",
  "frontchannel_logout_session_supported": true,
  "frontchannel_logout_supported": true,
  "jwks_uri": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/certs",
  "check_session_iframe": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/login-status-iframe.html",
  "grant_types_supported": [
    "authorization_code",
    "client_credentials",
    "implicit",
    "password",
    "refresh_token",
    "urn:ietf:params:oauth:grant-type:device_code",
    "urn:ietf:params:oauth:grant-type:token-exchange",
    "urn:ietf:params:oauth:grant-type:uma-ticket",
    "urn:openid:params:grant-type:ciba"
  ],
  "acr_values_supported": [
    "0",
    "1"
  ],
  "response_types_supported": [
    "code",
    "none",
    "id_token",
    "token",
    "id_token token",
    "code id_token",
    "code token",
    "code id_token token"
  ],
  "subject_types_supported": [
    "public",
    "pairwise"
  ],
  "prompt_values_supported": [
    "none",
    "login",
    "consent"
  ],
  # ...
  "response_modes_supported": [
    "query",
    "fragment",
    "form_post",
    "query.jwt",
    "fragment.jwt",
    "form_post.jwt",
    "jwt"
  ],
  "registration_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/clients-registrations/openid-connect",
  "token_endpoint_auth_methods_supported": [
    "private_key_jwt",
    "client_secret_basic",
    "client_secret_post",
    "tls_client_auth",
    "client_secret_jwt"
  ],
  "token_endpoint_auth_signing_alg_values_supported": [
    "PS384",
    "RS384",
    "EdDSA",
    "ES384",
    "HS256",
    "HS512",
    "ES256",
    "RS256",
    "HS384",
    "ES512",
    "PS256",
    "PS512",
    "RS512"
  ],
  "introspection_endpoint_auth_methods_supported": [
    "private_key_jwt",
    "client_secret_basic",
    "client_secret_post",
    "tls_client_auth",
    "client_secret_jwt"
  ],
  # ....
  "claims_supported": [
    "iss",
    "sub",
    "aud",
    "exp",
    "iat",
    "auth_time",
    "name",
    "given_name",
    "family_name",
    "preferred_username",
    "email",
    "acr",
    "azp",
    "nonce"
  ],
  "claim_types_supported": [
    "normal"
  ],
  "claims_parameter_supported": true,
  "scopes_supported": [
    "openid",
    "offline_access",
    "address",
    "profile",
    "microprofile-jwt",
    "web-origins",
    "phone",
    "danny_test_client_scope",
    "acr",
    "basic",
    "service_account",
    "email",
    "roles",
    "organization"
  ],
  "request_parameter_supported": true,
  "request_uri_parameter_supported": true,
  "require_request_uri_registration": true,
  "code_challenge_methods_supported": [
    "plain",
    "S256"
  ],
  "tls_client_certificate_bound_access_tokens": true,
  "dpop_signing_alg_values_supported": [
    "PS384",
    "RS384",
    "EdDSA",
    "ES384",
    "ES256",
    "RS256",
    "ES512",
    "PS256",
    "PS512",
    "RS512"
  ],
  "revocation_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/revoke",
  "revocation_endpoint_auth_methods_supported": [
    "private_key_jwt",
    "client_secret_basic",
    "client_secret_post",
    "tls_client_auth",
    "client_secret_jwt"
  ],
  "revocation_endpoint_auth_signing_alg_values_supported": [
    "PS384",
    "RS384",
    "EdDSA",
    "ES384",
    "HS256",
    "HS512",
    "ES256",
    "RS256",
    "HS384",
    "ES512",
    "PS256",
    "PS512",
    "RS512"
  ],
  "backchannel_logout_supported": true,
  "backchannel_logout_session_supported": true,
  "device_authorization_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/auth/device",
  "backchannel_token_delivery_modes_supported": [
    "poll",
    "ping"
  ],
  "backchannel_authentication_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/ext/ciba/auth",
  "backchannel_authentication_request_signing_alg_values_supported": [
    "PS384",
    "RS384",
    "EdDSA",
    "ES384",
    "ES256",
    "RS256",
    "ES512",
    "PS256",
    "PS512",
    "RS512"
  ],
  "require_pushed_authorization_requests": false,
  "pushed_authorization_request_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/ext/par/request",
  "mtls_endpoint_aliases": {
    "token_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/token",
    "revocation_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/revoke",
    "introspection_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/token/introspect",
    "device_authorization_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/auth/device",
    "registration_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/clients-registrations/openid-connect",
    "userinfo_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/userinfo",
    "pushed_authorization_request_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/ext/par/request",
    "backchannel_authentication_endpoint": "http://keycloak.keycloak:8080/realms/myrealm/protocol/openid-connect/ext/ciba/auth"
  },
  "authorization_response_iss_parameter_supported": true
}

provider 初始化

// github.com/coreos/go-oidc/v3@v3.14.1/oidc/oidc.go

func NewProvider(ctx context.Context, issuer string) (*Provider, error) {
	wellKnown := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
	req, err := http.NewRequest("GET", wellKnown, nil)
    // ...

	// 解析数据
	var p providerJSON
	err = unmarshalResp(resp, body, &p)
	if err != nil {
		return nil, fmt.Errorf("oidc: failed to decode provider discovery object: %v", err)
	}

	issuerURL, skipIssuerValidation := ctx.Value(issuerURLKey).(string)
	if !skipIssuerValidation {
		issuerURL = issuer
	}
	if p.Issuer != issuerURL && !skipIssuerValidation {
		return nil, fmt.Errorf("oidc: issuer did not match the issuer returned by provider, expected %q got %q", issuer, p.Issuer)
	}
	var algs []string
	for _, a := range p.Algorithms {
		if supportedAlgorithms[a] {
			algs = append(algs, a)
		}
	}
	return &Provider{
		issuer:        issuerURL,
		authURL:       p.AuthURL,
		tokenURL:      p.TokenURL,
		deviceAuthURL: p.DeviceAuthURL,
		userInfoURL:   p.UserInfoURL,
		jwksURL:       p.JWKSURL,
		algorithms:    algs,
		rawClaims:     body,
		client:        getClient(ctx),
	}, nil
}

keycloak 基本概念

Realm 领域

realm是管理用户和对应应用的空间

Master Realm中的管理员账户有权查看和管理在Keycloak服务器实例上创建的任何其它Realm。 其它Realm是指用Master创建的Realm。

Keycloak 中的角色有两种类型:

  • Realm Roles: 跨越整个 Realm(域)使用的角色,适用于所有客户端。
  • Client Roles: 特定客户端(应用程序)的角色,仅对某个客户端有效
scope 授权的范围
client 客户端

通常指一些需要向keycloak请求以认证一个用户的应用或者服务,甚至可以说寻求keycloak保护并在keycloak上注册的请求实体都是客户端。

client scope

keycloak中的client-scope允许你为每个客户端分配scope,而scope就是授权范围,它直接影响了token中的内容,及userinfo端点可以获取到的用户信息,

授权服务

授权服务包括下列三种REST端点:

  • Token Endpoint
  • Resource Management Endpoint
  • Permission Management Endpoint

自定义协议 Mapper

第三方应用–> argo workflow

内置的角色包括(以下都是 ClusterRole):

argo-aggregate-to-view

argo-aggregate-to-edit

argo-aggregate-to-admin

argo-cluster-role,没有 workfloweventbindings 的权限

argo-server-cluster-role,包含所有需要的权限

初始化 sso

func newSso(
	factory providerFactory,
	c Config,
	secretsIf corev1.SecretInterface,
	baseHRef string,
	secure bool,
) (Interface, error) {
    // ...

    // ClientID 与 ClientSecret 由授权服务器分配,Scopes 指定权限范围,Endpoint 对应授权与令牌端点。
	config := &oauth2.Config{
		ClientID:     string(clientID),
		ClientSecret: string(clientSecret),
		RedirectURL:  c.RedirectURL,
		Endpoint:     provider.Endpoint(), // AuthURL,TokenURL 等
		Scopes:       append(c.Scopes, oidc.ScopeOpenID),
	}
	idTokenVerifier := provider.Verifier(&oidc.Config{ClientID: config.ClientID})
	encrypter, err := jose.NewEncrypter(jose.A256GCM, jose.Recipient{Algorithm: jose.RSA_OAEP_256, Key: privateKey.Public()}, &jose.EncrypterOptions{Compression: jose.DEFLATE})
	if err != nil {
		return nil, fmt.Errorf("failed to create JWT encrpytor: %w", err)
	}

	var filterGroupsRegex []*regexp.Regexp
	if len(c.FilterGroupsRegex) > 0 {
		for _, regex := range c.FilterGroupsRegex {
			compiledRegex, err := regexp.Compile(regex)
			if err != nil {
				return nil, fmt.Errorf("failed to compile sso.filterGroupRegex: %s %w", regex, err)
			}
			filterGroupsRegex = append(filterGroupsRegex, compiledRegex)
		}
	}

	lf := log.Fields{"redirectUrl": config.RedirectURL, "issuer": c.Issuer, "issuerAlias": "DISABLED", "clientId": c.ClientID, "scopes": config.Scopes, "insecureSkipVerify": c.InsecureSkipVerify, "filterGroupsRegex": c.FilterGroupsRegex}
	if c.IssuerAlias != "" {
		lf["issuerAlias"] = c.IssuerAlias
	}
	log.WithFields(lf).Info("SSO configuration")

	return &sso{
		config:            config,
		idTokenVerifier:   idTokenVerifier,
		baseHRef:          baseHRef,
		httpClient:        httpClient,
		secure:            secure,
		privateKey:        privateKey,
		encrypter:         encrypter,
		rbacConfig:        c.RBAC,
		expiry:            c.GetSessionExpiry(),
		customClaimName:   c.CustomGroupClaimName,
		userInfoPath:      c.UserInfoPath,
		issuer:            c.Issuer,
		filterGroupsRegex: filterGroupsRegex,
	}, nil
}

调用地址

http://keycloak.keycloak.svc.cluster.local:8080/realms/myrealm/protocol/openid-connect/auth?
client_id=argo-workflow&redirect_uri=https://localhost:2746/oauth2/callback&response_type=code&scope=groups email profile openid&state=8dc3decc9f

/oauth2/redirect 处理

func (s *sso) HandleRedirect(w http.ResponseWriter, r *http.Request) {
	finalRedirectURL := r.URL.Query().Get("redirect")
	if !isValidFinalRedirectURL(finalRedirectURL) {
		finalRedirectURL = s.baseHRef
	}
	state, err := pkgrand.RandString(10)
	if err != nil {
		log.WithError(err).Error("failed to create state")
		w.WriteHeader(500)
		return
	}
	http.SetCookie(w, &http.Cookie{
		Name:     state,
		Value:    finalRedirectURL,
		Expires:  time.Now().Add(3 * time.Minute),
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
		Secure:   s.secure,
	})

	redirectOption := oauth2.SetAuthURLParam("redirect_uri", s.getRedirectURL(r))
	// 定向到 auth endpoint 
	http.Redirect(w, r, s.config.AuthCodeURL(state, redirectOption), http.StatusFound)
}

客户端申请授权,重定向到认证服务器的URI中需要包含这些参数:

func (c *Config) AuthCodeURL(state string, opts ...AuthCodeOption) string {
	var buf bytes.Buffer
	buf.WriteString(c.Endpoint.AuthURL)
	v := url.Values{
		"response_type": {"code"}, // 授权类型,此处的值为code, 必须
		"client_id":     {c.ClientID}, // 客户端ID,客户端到资源服务器注册的ID	必须
	} 
	if c.RedirectURL != "" { // 重定向URI	可选
		v.Set("redirect_uri", c.RedirectURL)
	}
	if len(c.Scopes) > 0 { // 申请的权限范围,多个逗号隔开	可选
		v.Set("scope", strings.Join(c.Scopes, " "))
	}
	if state != "" { // 客户端的当前状态,可以指定任意值,认证服务器会原封不动的返回这个值	推荐
		v.Set("state", state)
	}
	for _, opt := range opts {
		opt.setValue(v)
	}
	if strings.Contains(c.Endpoint.AuthURL, "?") {
		buf.WriteByte('&')
	} else {
		buf.WriteByte('?')
	}
	buf.WriteString(v.Encode())
	return buf.String()
}

调用地址

https://localhost:2746/oauth2/callback?
state=14d97e5995&session_state=e88425bf-d9ef-b206-1c09-00a9e5cab71b&iss=http://keycloak.keycloak.svc.cluster.local:8080/realms/myrealm&code=88f91156-e4c2-5d54-0ad3-84eb30a74bfc.e88425bf-d9ef-b206-1c09-00a9e5cab71b.30f2278c-0d09-447a-8dcf-2d95defd4e95

/oauth2/callback 处理

// https://github.com/argoproj/argo-workflows/blob/a4f457eace1193b07f81999f31243f99ff620966/server/auth/sso/sso.go

func (s *sso) HandleCallback(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	state := r.URL.Query().Get("state")
	cookie, err := r.Cookie(state)
	http.SetCookie(w, &http.Cookie{Name: state, MaxAge: 0})
	if err != nil {
		log.WithError(err).Error("failed to get cookie")
		w.WriteHeader(400)
		return
	}
	
	// 将 authorization code 转成 token 
	redirectOption := oauth2.SetAuthURLParam("redirect_uri", s.getRedirectURL(r))
	// Use sso.httpClient in order to respect TLSOptions
	oauth2Context := context.WithValue(ctx, oauth2.HTTPClient, s.httpClient)
	oauth2Token, err := s.config.Exchange(oauth2Context, r.URL.Query().Get("code"), redirectOption)
	if err != nil {
		log.WithError(err).Error("failed to get oauth2Token by using code from the oauth2 server")
		w.WriteHeader(401)
		return
	}
	rawIDToken, ok := oauth2Token.Extra("id_token").(string)
	if !ok {
		log.Error("failed to extract id_token from the response")
		w.WriteHeader(401)
		return
	}
	idToken, err := s.idTokenVerifier.Verify(ctx, rawIDToken)
	if err != nil {
		log.WithError(err).Error("failed to verify the id token issued")
		w.WriteHeader(401)
		return
	}
	c := &types.Claims{}
	if err := idToken.Claims(c); err != nil {
		log.WithError(err).Error("failed to get claims from the id token")
		w.WriteHeader(401)
		return
	}
	// Default to groups claim but if customClaimName is set
	// extract groups based on that claim key
	groups := c.Groups
	if s.customClaimName != "" {
		groups, err = c.GetCustomGroup(s.customClaimName)
		if err != nil {
			log.Warn(err)
		}
	}
	// Some SSO implementations (Okta) require a call to
	// the OIDC user info path to get attributes like groups
	if s.userInfoPath != "" {
		groups, err = c.GetUserInfoGroups(s.httpClient, oauth2Token.AccessToken, s.issuer, s.userInfoPath)
		if err != nil {
			log.WithError(err).Errorf("failed to get groups claim from the given userInfoPath(%s)", s.userInfoPath)
			w.WriteHeader(401)
			return
		}
	}

	// only return groups that match at least one of the regexes
	if len(s.filterGroupsRegex) > 0 {
		var filteredGroups []string
		for _, group := range groups {
			for _, regex := range s.filterGroupsRegex {
				if regex.MatchString(group) {
					filteredGroups = append(filteredGroups, group)
					break
				}
			}
		}
		groups = filteredGroups
	}

	argoClaims := &types.Claims{
		Claims: jwt.Claims{
			Issuer:  issuer,
			Subject: c.Subject,
			Expiry:  jwt.NewNumericDate(time.Now().Add(s.expiry)),
		},
		Groups:                  groups,
		Email:                   c.Email,
		EmailVerified:           c.EmailVerified,
		Name:                    c.Name,
		ServiceAccountName:      c.ServiceAccountName,
		PreferredUsername:       c.PreferredUsername,
		ServiceAccountNamespace: c.ServiceAccountNamespace,
	}
	raw, err := jwt.Encrypted(s.encrypter).Claims(argoClaims).CompactSerialize()
	if err != nil {
		log.WithError(err).Errorf("failed to encrypt and serialize the jwt token")
		w.WriteHeader(401)
		return
	}
	value := Prefix + raw
	log.Debugf("handing oauth2 callback %v", value)
	http.SetCookie(w, &http.Cookie{
		Value:    value,
		Name:     "authorization",
		Path:     s.baseHRef,
		Expires:  time.Now().Add(s.expiry),
		SameSite: http.SameSiteStrictMode,
		Secure:   s.secure,
	})

	finalRedirectURL := cookie.Value
	if !isValidFinalRedirectURL(cookie.Value) {
		finalRedirectURL = s.baseHRef

	}
	http.Redirect(w, r, finalRedirectURL, http.StatusFound)
}

参考

Danny
Authors
Devops
Life is short 人生苦短,及时行乐.