Files
kubesphere/pkg/apiserver/authentication/identityprovider/oidc/oidc.go
2025-04-30 15:53:51 +08:00

299 lines
10 KiB
Go

/*
* Copyright 2024 the KubeSphere Authors.
* Please refer to the LICENSE file in the root directory of the project.
* https://github.com/kubesphere/kubesphere/blob/master/LICENSE
*/
package oidc
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v4"
"github.com/mitchellh/mapstructure"
"golang.org/x/oauth2"
"kubesphere.io/kubesphere/pkg/apiserver/authentication/identityprovider"
"kubesphere.io/kubesphere/pkg/server/options"
"kubesphere.io/kubesphere/pkg/utils/sliceutil"
)
func init() {
identityprovider.RegisterOAuthProviderFactory(&oidcProviderFactory{})
}
type oidcProvider struct {
// Defines how Clients dynamically discover information about OpenID Providers
// See also, https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig
Issuer string `json:"issuer,omitempty" yaml:"issuer,omitempty"`
// ClientID is the application's ID.
ClientID string `json:"clientID" yaml:"clientID"`
// ClientSecret is the application's secret.
ClientSecret string `json:"-" yaml:"clientSecret"`
// Endpoint contains the resource server's token endpoint URLs.
// These are constants specific to each server and are often available via site-specific packages,
// such as google.Endpoint or github.Endpoint.
Endpoint endpoint `json:"endpoint" yaml:"endpoint"`
// RedirectURL is the URL to redirect users going through
// the OAuth flow, after the resource owner's URLs.
RedirectURL string `json:"redirectURL" yaml:"redirectURL"`
// Scope specifies optional requested permissions.
Scopes []string `json:"scopes" yaml:"scopes"`
// Redirection to RP After Logout
// See https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RedirectionAfterLogout
PostLogoutRedirectURI string `json:"postLogoutRedirectURI" yaml:"postLogoutRedirectURI"`
// GetUserInfo uses the userinfo endpoint to get additional claims for the token.
// This is especially useful where upstreams return "thin" id tokens
// See also, https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
GetUserInfo bool `json:"getUserInfo" yaml:"getUserInfo"`
// Used to turn off TLS certificate checks
InsecureSkipVerify bool `json:"insecureSkipVerify" yaml:"insecureSkipVerify"`
// Configurable key which contains the email claims
EmailKey string `json:"emailKey" yaml:"emailKey"`
// Configurable key which contains the preferred username claims
PreferredUsernameKey string `json:"preferredUsernameKey" yaml:"preferredUsernameKey"`
Provider *oidc.Provider `json:"-" yaml:"-"`
OAuth2Config *oauth2.Config `json:"-" yaml:"-"`
Verifier *oidc.IDTokenVerifier `json:"-" yaml:"-"`
}
// endpoint represents an OAuth 2.0 provider's authorization and token
// endpoint URLs.
type endpoint struct {
// URL of the OP's OAuth 2.0 Authorization Endpoint [OpenID.Core](https://openid.net/specs/openid-connect-discovery-1_0.html#OpenID.Core).
AuthURL string `json:"authURL" yaml:"authURL"`
// URL of the OP's OAuth 2.0 Token Endpoint [OpenID.Core](https://openid.net/specs/openid-connect-discovery-1_0.html#OpenID.Core).
// This is REQUIRED unless only the Implicit Flow is used.
TokenURL string `json:"tokenURL" yaml:"tokenURL"`
// URL of the OP's UserInfo Endpoint [OpenID.Core](https://openid.net/specs/openid-connect-discovery-1_0.html#OpenID.Core).
// This URL MUST use the https scheme and MAY contain port, path, and query parameter components.
UserInfoURL string `json:"userInfoURL" yaml:"userInfoURL"`
// URL of the OP's JSON Web Key Set [JWK](https://openid.net/specs/openid-connect-discovery-1_0.html#JWK) document.
JWKSURL string `json:"jwksURL"`
// URL at the OP to which an RP can perform a redirect to request that the End-User be logged out at the OP.
// This URL MUST use the https scheme and MAY contain port, path, and query parameter components.
// https://openid.net/specs/openid-connect-rpinitiated-1_0.html#OPMetadata
EndSessionURL string `json:"endSessionURL"`
}
type oidcIdentity struct {
// Subject - Identifier for the End-User at the Issuer.
Sub string `json:"sub"`
// Shorthand name by which the End-User wishes to be referred to at the RP,
// such as janedoe or j.doe. This value MAY be any valid JSON string including special characters such as @, /, or whitespace.
// The RP MUST NOT rely upon this value being unique
PreferredUsername string `json:"preferred_username"`
// End-User's preferred e-mail address.
// Its value MUST conform to the RFC 5322 [RFC5322] addr-spec syntax.
// The RP MUST NOT rely upon this value being unique.
Email string `json:"email"`
}
func (o oidcIdentity) GetUserID() string {
return o.Sub
}
func (o oidcIdentity) GetUsername() string {
return o.PreferredUsername
}
func (o oidcIdentity) GetEmail() string {
return o.Email
}
type oidcProviderFactory struct {
}
func (f *oidcProviderFactory) Type() string {
return "OIDCIdentityProvider"
}
func (f *oidcProviderFactory) Create(opts options.DynamicOptions) (identityprovider.OAuthProvider, error) {
var oidcProvider oidcProvider
if err := mapstructure.Decode(opts, &oidcProvider); err != nil {
return nil, err
}
// dynamically discover
if oidcProvider.Issuer != "" {
ctx := context.TODO()
if oidcProvider.InsecureSkipVerify {
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
ctx = oidc.ClientContext(ctx, client)
}
provider, err := oidc.NewProvider(ctx, oidcProvider.Issuer)
if err != nil {
return nil, fmt.Errorf("failed to create oidc provider: %v", err)
}
var providerJSON map[string]interface{}
if err = provider.Claims(&providerJSON); err != nil {
return nil, fmt.Errorf("failed to decode oidc provider claims: %v", err)
}
oidcProvider.Endpoint.AuthURL, _ = providerJSON["authorization_endpoint"].(string)
oidcProvider.Endpoint.TokenURL, _ = providerJSON["token_endpoint"].(string)
oidcProvider.Endpoint.UserInfoURL, _ = providerJSON["userinfo_endpoint"].(string)
oidcProvider.Endpoint.JWKSURL, _ = providerJSON["jwks_uri"].(string)
oidcProvider.Endpoint.EndSessionURL, _ = providerJSON["end_session_endpoint"].(string)
endSessionUrl, err := url.Parse(oidcProvider.Endpoint.EndSessionURL)
if err != nil {
return nil, fmt.Errorf("failed to parse end session url: %v", err)
}
endSessionQuery := endSessionUrl.Query()
endSessionQuery.Add("post_logout_redirect_uri", oidcProvider.PostLogoutRedirectURI)
endSessionQuery.Add("client_id", oidcProvider.ClientID)
endSessionUrl.RawQuery = endSessionQuery.Encode()
oidcProvider.Endpoint.EndSessionURL = endSessionUrl.String()
oidcProvider.Provider = provider
oidcProvider.Verifier = provider.Verifier(&oidc.Config{
// TODO: support HS256
ClientID: oidcProvider.ClientID,
})
opts["endpoint"] = options.DynamicOptions{
"authURL": oidcProvider.Endpoint.AuthURL,
"tokenURL": oidcProvider.Endpoint.TokenURL,
"userInfoURL": oidcProvider.Endpoint.UserInfoURL,
"jwksURL": oidcProvider.Endpoint.JWKSURL,
"endSessionURL": oidcProvider.Endpoint.EndSessionURL,
}
}
scopes := []string{oidc.ScopeOpenID}
if !sliceutil.HasString(oidcProvider.Scopes, oidc.ScopeOpenID) {
scopes = append(scopes, oidcProvider.Scopes...)
}
oidcProvider.Scopes = scopes
oidcProvider.OAuth2Config = &oauth2.Config{
ClientID: oidcProvider.ClientID,
ClientSecret: oidcProvider.ClientSecret,
Endpoint: oauth2.Endpoint{
TokenURL: oidcProvider.Endpoint.TokenURL,
AuthURL: oidcProvider.Endpoint.AuthURL,
},
RedirectURL: oidcProvider.RedirectURL,
Scopes: oidcProvider.Scopes,
}
return &oidcProvider, nil
}
func (o *oidcProvider) IdentityExchangeCallback(req *http.Request) (identityprovider.Identity, error) {
//OAuth2 callback, see also https://tools.ietf.org/html/rfc6749#section-4.1.2
code := req.URL.Query().Get("code")
ctx := req.Context()
if o.InsecureSkipVerify {
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, client)
}
token, err := o.OAuth2Config.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("oidc: failed to get token: %v", err)
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, errors.New("no id_token in token response")
}
var claims jwt.MapClaims
if o.Verifier != nil {
idToken, err := o.Verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("failed to verify id token: %v", err)
}
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("failed to decode id token claims: %v", err)
}
} else {
_, _, err := new(jwt.Parser).ParseUnverified(rawIDToken, &claims)
if err != nil {
return nil, fmt.Errorf("failed to decode id token claims: %v", err)
}
if err := claims.Valid(); err != nil {
return nil, fmt.Errorf("failed to verify id token: %v", err)
}
}
if o.GetUserInfo {
if o.Provider != nil {
userInfo, err := o.Provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
if err != nil {
return nil, fmt.Errorf("failed to fetch userinfo: %v", err)
}
if err := userInfo.Claims(&claims); err != nil {
return nil, fmt.Errorf("failed to decode userinfo claims: %v", err)
}
} else {
resp, err := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token)).Get(o.Endpoint.UserInfoURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch userinfo: %v", err)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to fetch userinfo: %v", err)
}
_ = resp.Body.Close()
if err := json.Unmarshal(data, &claims); err != nil {
return nil, fmt.Errorf("failed to decode userinfo claims: %v", err)
}
}
}
subject, ok := claims["sub"].(string)
if !ok {
return nil, errors.New("missing required claim \"sub\"")
}
var email string
emailKey := "email"
if o.EmailKey != "" {
emailKey = o.EmailKey
}
email, _ = claims[emailKey].(string)
var preferredUsername string
preferredUsernameKey := "preferred_username"
if o.PreferredUsernameKey != "" {
preferredUsernameKey = o.PreferredUsernameKey
}
preferredUsername, _ = claims[preferredUsernameKey].(string)
if preferredUsername == "" {
preferredUsername, _ = claims["name"].(string)
}
return &oidcIdentity{
Sub: subject,
PreferredUsername: preferredUsername,
Email: email,
}, nil
}