384 lines
11 KiB
Go
384 lines
11 KiB
Go
/*
|
|
Copyright 2015 The Kubernetes Authors.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
/*
|
|
oidc implements the authenticator.Token interface using the OpenID Connect protocol.
|
|
|
|
config := oidc.Options{
|
|
IssuerURL: "https://accounts.google.com",
|
|
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
|
|
UsernameClaim: "email",
|
|
}
|
|
tokenAuthenticator, err := oidc.New(config)
|
|
*/
|
|
package oidc
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
oidc "github.com/coreos/go-oidc"
|
|
"github.com/golang/glog"
|
|
"k8s.io/apimachinery/pkg/util/net"
|
|
"k8s.io/apimachinery/pkg/util/wait"
|
|
"k8s.io/apiserver/pkg/authentication/user"
|
|
certutil "k8s.io/client-go/util/cert"
|
|
)
|
|
|
|
type Options struct {
|
|
// IssuerURL is the URL the provider signs ID Tokens as. This will be the "iss"
|
|
// field of all tokens produced by the provider and is used for configuration
|
|
// discovery.
|
|
//
|
|
// The URL is usually the provider's URL without a path, for example
|
|
// "https://accounts.google.com" or "https://login.salesforce.com".
|
|
//
|
|
// The provider must implement configuration discovery.
|
|
// See: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig
|
|
IssuerURL string
|
|
|
|
// ClientID the JWT must be issued for, the "sub" field. This plugin only trusts a single
|
|
// client to ensure the plugin can be used with public providers.
|
|
//
|
|
// The plugin supports the "authorized party" OpenID Connect claim, which allows
|
|
// specialized providers to issue tokens to a client for a different client.
|
|
// See: https://openid.net/specs/openid-connect-core-1_0.html#IDToken
|
|
ClientID string
|
|
|
|
// Path to a PEM encoded root certificate of the provider.
|
|
CAFile string
|
|
|
|
// UsernameClaim is the JWT field to use as the user's username.
|
|
UsernameClaim string
|
|
|
|
// UsernamePrefix, if specified, causes claims mapping to username to be prefix with
|
|
// the provided value. A value "oidc:" would result in usernames like "oidc:john".
|
|
UsernamePrefix string
|
|
|
|
// GroupsClaim, if specified, causes the OIDCAuthenticator to try to populate the user's
|
|
// groups with an ID Token field. If the GrouppClaim field is present in an ID Token the value
|
|
// must be a string or list of strings.
|
|
GroupsClaim string
|
|
|
|
// GroupsPrefix, if specified, causes claims mapping to group names to be prefixed with the
|
|
// value. A value "oidc:" would result in groups like "oidc:engineering" and "oidc:marketing".
|
|
GroupsPrefix string
|
|
|
|
// SupportedSigningAlgs sets the accepted set of JOSE signing algorithms that
|
|
// can be used by the provider to sign tokens.
|
|
//
|
|
// https://tools.ietf.org/html/rfc7518#section-3.1
|
|
//
|
|
// This value defaults to RS256, the value recommended by the OpenID Connect
|
|
// spec:
|
|
//
|
|
// https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
|
|
SupportedSigningAlgs []string
|
|
|
|
// RequiredClaims, if specified, causes the OIDCAuthenticator to verify that all the
|
|
// required claims key value pairs are present in the ID Token.
|
|
RequiredClaims map[string]string
|
|
|
|
// now is used for testing. It defaults to time.Now.
|
|
now func() time.Time
|
|
}
|
|
|
|
type Authenticator struct {
|
|
issuerURL string
|
|
|
|
usernameClaim string
|
|
usernamePrefix string
|
|
groupsClaim string
|
|
groupsPrefix string
|
|
requiredClaims map[string]string
|
|
|
|
// Contains an *oidc.IDTokenVerifier. Do not access directly use the
|
|
// idTokenVerifier method.
|
|
verifier atomic.Value
|
|
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func (a *Authenticator) setVerifier(v *oidc.IDTokenVerifier) {
|
|
a.verifier.Store(v)
|
|
}
|
|
|
|
func (a *Authenticator) idTokenVerifier() (*oidc.IDTokenVerifier, bool) {
|
|
if v := a.verifier.Load(); v != nil {
|
|
return v.(*oidc.IDTokenVerifier), true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func (a *Authenticator) Close() {
|
|
a.cancel()
|
|
}
|
|
|
|
func New(opts Options) (*Authenticator, error) {
|
|
return newAuthenticator(opts, func(ctx context.Context, a *Authenticator, config *oidc.Config) {
|
|
// Asynchronously attempt to initialize the authenticator. This enables
|
|
// self-hosted providers, providers that run on top of Kubernetes itself.
|
|
go wait.PollUntil(time.Second*10, func() (done bool, err error) {
|
|
provider, err := oidc.NewProvider(ctx, a.issuerURL)
|
|
if err != nil {
|
|
glog.Errorf("oidc authenticator: initializing plugin: %v", err)
|
|
return false, nil
|
|
}
|
|
|
|
verifier := provider.Verifier(config)
|
|
a.setVerifier(verifier)
|
|
return true, nil
|
|
}, ctx.Done())
|
|
})
|
|
}
|
|
|
|
// whitelist of signing algorithms to ensure users don't mistakenly pass something
|
|
// goofy.
|
|
var allowedSigningAlgs = map[string]bool{
|
|
oidc.RS256: true,
|
|
oidc.RS384: true,
|
|
oidc.RS512: true,
|
|
oidc.ES256: true,
|
|
oidc.ES384: true,
|
|
oidc.ES512: true,
|
|
oidc.PS256: true,
|
|
oidc.PS384: true,
|
|
oidc.PS512: true,
|
|
}
|
|
|
|
func newAuthenticator(opts Options, initVerifier func(ctx context.Context, a *Authenticator, config *oidc.Config)) (*Authenticator, error) {
|
|
url, err := url.Parse(opts.IssuerURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if url.Scheme != "https" {
|
|
return nil, fmt.Errorf("'oidc-issuer-url' (%q) has invalid scheme (%q), require 'https'", opts.IssuerURL, url.Scheme)
|
|
}
|
|
|
|
if opts.UsernameClaim == "" {
|
|
return nil, errors.New("no username claim provided")
|
|
}
|
|
|
|
supportedSigningAlgs := opts.SupportedSigningAlgs
|
|
if len(supportedSigningAlgs) == 0 {
|
|
// RS256 is the default recommended by OpenID Connect and an 'alg' value
|
|
// providers are required to implement.
|
|
supportedSigningAlgs = []string{oidc.RS256}
|
|
}
|
|
for _, alg := range supportedSigningAlgs {
|
|
if !allowedSigningAlgs[alg] {
|
|
return nil, fmt.Errorf("oidc: unsupported signing alg: %q", alg)
|
|
}
|
|
}
|
|
|
|
var roots *x509.CertPool
|
|
if opts.CAFile != "" {
|
|
roots, err = certutil.NewPool(opts.CAFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to read the CA file: %v", err)
|
|
}
|
|
} else {
|
|
glog.Info("OIDC: No x509 certificates provided, will use host's root CA set")
|
|
}
|
|
|
|
// Copied from http.DefaultTransport.
|
|
tr := net.SetTransportDefaults(&http.Transport{
|
|
// According to golang's doc, if RootCAs is nil,
|
|
// TLS uses the host's root CA set.
|
|
TLSClientConfig: &tls.Config{RootCAs: roots},
|
|
})
|
|
|
|
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
ctx = oidc.ClientContext(ctx, client)
|
|
|
|
authenticator := &Authenticator{
|
|
issuerURL: opts.IssuerURL,
|
|
usernameClaim: opts.UsernameClaim,
|
|
usernamePrefix: opts.UsernamePrefix,
|
|
groupsClaim: opts.GroupsClaim,
|
|
groupsPrefix: opts.GroupsPrefix,
|
|
requiredClaims: opts.RequiredClaims,
|
|
cancel: cancel,
|
|
}
|
|
|
|
now := opts.now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
|
|
verifierConfig := &oidc.Config{
|
|
ClientID: opts.ClientID,
|
|
SupportedSigningAlgs: supportedSigningAlgs,
|
|
Now: now,
|
|
}
|
|
|
|
initVerifier(ctx, authenticator, verifierConfig)
|
|
return authenticator, nil
|
|
}
|
|
|
|
func hasCorrectIssuer(iss, tokenData string) bool {
|
|
parts := strings.Split(tokenData, ".")
|
|
if len(parts) != 3 {
|
|
return false
|
|
}
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
claims := struct {
|
|
// WARNING: this JWT is not verified. Do not trust these claims.
|
|
Issuer string `json:"iss"`
|
|
}{}
|
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
return false
|
|
}
|
|
if claims.Issuer != iss {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (a *Authenticator) AuthenticateToken(token string) (user.Info, bool, error) {
|
|
if !hasCorrectIssuer(a.issuerURL, token) {
|
|
return nil, false, nil
|
|
}
|
|
|
|
ctx := context.Background()
|
|
verifier, ok := a.idTokenVerifier()
|
|
if !ok {
|
|
return nil, false, fmt.Errorf("oidc: authenticator not initialized")
|
|
}
|
|
|
|
idToken, err := verifier.Verify(ctx, token)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("oidc: verify token: %v", err)
|
|
}
|
|
|
|
var c claims
|
|
if err := idToken.Claims(&c); err != nil {
|
|
return nil, false, fmt.Errorf("oidc: parse claims: %v", err)
|
|
}
|
|
var username string
|
|
if err := c.unmarshalClaim(a.usernameClaim, &username); err != nil {
|
|
return nil, false, fmt.Errorf("oidc: parse username claims %q: %v", a.usernameClaim, err)
|
|
}
|
|
|
|
if a.usernameClaim == "email" {
|
|
// If the email_verified claim is present, ensure the email is valid.
|
|
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
|
if hasEmailVerified := c.hasClaim("email_verified"); hasEmailVerified {
|
|
var emailVerified bool
|
|
if err := c.unmarshalClaim("email_verified", &emailVerified); err != nil {
|
|
return nil, false, fmt.Errorf("oidc: parse 'email_verified' claim: %v", err)
|
|
}
|
|
|
|
// If the email_verified claim is present we have to verify it is set to `true`.
|
|
if !emailVerified {
|
|
return nil, false, fmt.Errorf("oidc: email not verified")
|
|
}
|
|
}
|
|
}
|
|
|
|
if a.usernamePrefix != "" {
|
|
username = a.usernamePrefix + username
|
|
}
|
|
|
|
info := &user.DefaultInfo{Name: username}
|
|
if a.groupsClaim != "" {
|
|
if _, ok := c[a.groupsClaim]; ok {
|
|
// Some admins want to use string claims like "role" as the group value.
|
|
// Allow the group claim to be a single string instead of an array.
|
|
//
|
|
// See: https://github.com/kubernetes/kubernetes/issues/33290
|
|
var groups stringOrArray
|
|
if err := c.unmarshalClaim(a.groupsClaim, &groups); err != nil {
|
|
return nil, false, fmt.Errorf("oidc: parse groups claim %q: %v", a.groupsClaim, err)
|
|
}
|
|
info.Groups = []string(groups)
|
|
}
|
|
}
|
|
|
|
if a.groupsPrefix != "" {
|
|
for i, group := range info.Groups {
|
|
info.Groups[i] = a.groupsPrefix + group
|
|
}
|
|
}
|
|
|
|
// check to ensure all required claims are present in the ID token and have matching values.
|
|
for claim, value := range a.requiredClaims {
|
|
if !c.hasClaim(claim) {
|
|
return nil, false, fmt.Errorf("oidc: required claim %s not present in ID token", claim)
|
|
}
|
|
|
|
// NOTE: Only string values are supported as valid required claim values.
|
|
var claimValue string
|
|
if err := c.unmarshalClaim(claim, &claimValue); err != nil {
|
|
return nil, false, fmt.Errorf("oidc: parse claim %s: %v", claim, err)
|
|
}
|
|
if claimValue != value {
|
|
return nil, false, fmt.Errorf("oidc: required claim %s value does not match. Got = %s, want = %s", claim, claimValue, value)
|
|
}
|
|
}
|
|
|
|
return info, true, nil
|
|
}
|
|
|
|
type stringOrArray []string
|
|
|
|
func (s *stringOrArray) UnmarshalJSON(b []byte) error {
|
|
var a []string
|
|
if err := json.Unmarshal(b, &a); err == nil {
|
|
*s = a
|
|
return nil
|
|
}
|
|
var str string
|
|
if err := json.Unmarshal(b, &str); err != nil {
|
|
return err
|
|
}
|
|
*s = []string{str}
|
|
return nil
|
|
}
|
|
|
|
type claims map[string]json.RawMessage
|
|
|
|
func (c claims) unmarshalClaim(name string, v interface{}) error {
|
|
val, ok := c[name]
|
|
if !ok {
|
|
return fmt.Errorf("claim not present")
|
|
}
|
|
return json.Unmarshal([]byte(val), v)
|
|
}
|
|
|
|
func (c claims) hasClaim(name string) bool {
|
|
if _, ok := c[name]; !ok {
|
|
return false
|
|
}
|
|
return true
|
|
}
|