299 lines
10 KiB
Go
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
|
|
}
|