84c3c3aa9c
- Add `AllowPasswordGrant` boolean field to OIDCIdentityProvider's spec - The oidc upstream watcher controller copies the value of `AllowPasswordGrant` into the configuration of the cached provider - Add password grant to the UpstreamOIDCIdentityProviderI interface which is implemented by the cached provider instance for use in the authorization endpoint - Enhance the IDP discovery endpoint to return the supported "flows" for each IDP ("cli_password" and/or "browser_authcode") - Enhance `pinniped get kubeconfig` to help the user choose the desired flow for the selected IDP, and to write the flow into the resulting kubeconfg - Enhance `pinniped login oidc` to have a flow flag to tell it which client-side flow it should use for auth (CLI-based or browser-based) - In the Dex config, allow the resource owner password grant, which Dex implements to also return ID tokens, for use in integration tests - Enhance the authorize endpoint to perform password grant when requested by the incoming headers. This commit does not include unit tests for the enhancements to the authorize endpoint, which will come in the next commit - Extract some shared helpers from the callback endpoint to share the code with the authorize endpoint - Add new integration tests
193 lines
6.7 KiB
Go
193 lines
6.7 KiB
Go
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
// Package upstreamoidc implements an abstraction of upstream OIDC provider interactions.
|
|
package upstreamoidc
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
|
|
"golang.org/x/oauth2"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
|
|
"go.pinniped.dev/internal/httputil/httperr"
|
|
"go.pinniped.dev/internal/oidc"
|
|
"go.pinniped.dev/internal/oidc/provider"
|
|
"go.pinniped.dev/internal/plog"
|
|
"go.pinniped.dev/pkg/oidcclient/nonce"
|
|
"go.pinniped.dev/pkg/oidcclient/oidctypes"
|
|
"go.pinniped.dev/pkg/oidcclient/pkce"
|
|
)
|
|
|
|
func New(config *oauth2.Config, provider *coreosoidc.Provider, client *http.Client) provider.UpstreamOIDCIdentityProviderI {
|
|
return &ProviderConfig{Config: config, Provider: provider, Client: client}
|
|
}
|
|
|
|
// ProviderConfig holds the active configuration of an upstream OIDC provider.
|
|
type ProviderConfig struct {
|
|
Name string
|
|
UsernameClaim string
|
|
GroupsClaim string
|
|
Config *oauth2.Config
|
|
Client *http.Client
|
|
AllowPasswordGrant bool
|
|
Provider interface {
|
|
Verifier(*coreosoidc.Config) *coreosoidc.IDTokenVerifier
|
|
UserInfo(ctx context.Context, tokenSource oauth2.TokenSource) (*coreosoidc.UserInfo, error)
|
|
}
|
|
}
|
|
|
|
func (p *ProviderConfig) GetName() string {
|
|
return p.Name
|
|
}
|
|
|
|
func (p *ProviderConfig) GetClientID() string {
|
|
return p.Config.ClientID
|
|
}
|
|
|
|
func (p *ProviderConfig) GetAuthorizationURL() *url.URL {
|
|
result, _ := url.Parse(p.Config.Endpoint.AuthURL)
|
|
return result
|
|
}
|
|
|
|
func (p *ProviderConfig) GetScopes() []string {
|
|
return p.Config.Scopes
|
|
}
|
|
|
|
func (p *ProviderConfig) GetUsernameClaim() string {
|
|
return p.UsernameClaim
|
|
}
|
|
|
|
func (p *ProviderConfig) GetGroupsClaim() string {
|
|
return p.GroupsClaim
|
|
}
|
|
|
|
func (p *ProviderConfig) AllowsPasswordGrant() bool {
|
|
return p.AllowPasswordGrant
|
|
}
|
|
|
|
func (p *ProviderConfig) PasswordCredentialsGrantAndValidateTokens(ctx context.Context, username, password string) (*oidctypes.Token, error) {
|
|
// Disallow this grant when requested.
|
|
if !p.AllowPasswordGrant {
|
|
return nil, fmt.Errorf("resource owner password grant is not allowed for this upstream provider according to its configuration")
|
|
}
|
|
|
|
// Note that this implicitly uses the scopes from p.Config.Scopes.
|
|
tok, err := p.Config.PasswordCredentialsToken(
|
|
coreosoidc.ClientContext(ctx, p.Client),
|
|
username,
|
|
password,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// There is no nonce to validate for a resource owner password credentials grant because it skips using
|
|
// the authorize endpoint and goes straight to the token endpoint.
|
|
skipNonceValidation := nonce.Nonce("")
|
|
return p.ValidateToken(ctx, tok, skipNonceValidation)
|
|
}
|
|
|
|
func (p *ProviderConfig) ExchangeAuthcodeAndValidateTokens(ctx context.Context, authcode string, pkceCodeVerifier pkce.Code, expectedIDTokenNonce nonce.Nonce, redirectURI string) (*oidctypes.Token, error) {
|
|
tok, err := p.Config.Exchange(
|
|
coreosoidc.ClientContext(ctx, p.Client),
|
|
authcode,
|
|
pkceCodeVerifier.Verifier(),
|
|
oauth2.SetAuthURLParam("redirect_uri", redirectURI),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return p.ValidateToken(ctx, tok, expectedIDTokenNonce)
|
|
}
|
|
|
|
func (p *ProviderConfig) ValidateToken(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) {
|
|
idTok, hasIDTok := tok.Extra("id_token").(string)
|
|
if !hasIDTok {
|
|
return nil, httperr.New(http.StatusBadRequest, "received response missing ID token")
|
|
}
|
|
validated, err := p.Provider.Verifier(&coreosoidc.Config{ClientID: p.GetClientID()}).Verify(coreosoidc.ClientContext(ctx, p.Client), idTok)
|
|
if err != nil {
|
|
return nil, httperr.Wrap(http.StatusBadRequest, "received invalid ID token", err)
|
|
}
|
|
if validated.AccessTokenHash != "" {
|
|
if err := validated.VerifyAccessToken(tok.AccessToken); err != nil {
|
|
return nil, httperr.Wrap(http.StatusBadRequest, "received invalid ID token", err)
|
|
}
|
|
}
|
|
if expectedIDTokenNonce != "" {
|
|
if err := expectedIDTokenNonce.Validate(validated); err != nil {
|
|
return nil, httperr.Wrap(http.StatusBadRequest, "received ID token with invalid nonce", err)
|
|
}
|
|
}
|
|
|
|
var validatedClaims map[string]interface{}
|
|
if err := validated.Claims(&validatedClaims); err != nil {
|
|
return nil, httperr.Wrap(http.StatusInternalServerError, "could not unmarshal id token claims", err)
|
|
}
|
|
plog.All("claims from ID token", "providerName", p.Name, "claims", validatedClaims)
|
|
|
|
if err := p.fetchUserInfo(ctx, tok, validatedClaims); err != nil {
|
|
return nil, httperr.Wrap(http.StatusInternalServerError, "could not fetch user info claims", err)
|
|
}
|
|
plog.All("claims from ID token and userinfo", "providerName", p.Name, "claims", validatedClaims)
|
|
|
|
return &oidctypes.Token{
|
|
AccessToken: &oidctypes.AccessToken{
|
|
Token: tok.AccessToken,
|
|
Type: tok.TokenType,
|
|
Expiry: metav1.NewTime(tok.Expiry),
|
|
},
|
|
RefreshToken: &oidctypes.RefreshToken{
|
|
Token: tok.RefreshToken,
|
|
},
|
|
IDToken: &oidctypes.IDToken{
|
|
Token: idTok,
|
|
Expiry: metav1.NewTime(validated.Expiry),
|
|
Claims: validatedClaims,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (p *ProviderConfig) fetchUserInfo(ctx context.Context, tok *oauth2.Token, claims map[string]interface{}) error {
|
|
idTokenSubject, _ := claims[oidc.IDTokenSubjectClaim].(string)
|
|
if len(idTokenSubject) == 0 {
|
|
return nil // defer to existing ID token validation
|
|
}
|
|
|
|
userInfo, err := p.Provider.UserInfo(coreosoidc.ClientContext(ctx, p.Client), oauth2.StaticTokenSource(tok))
|
|
if err != nil {
|
|
// the user info endpoint is not required but we do not have a good way to probe if it was provided
|
|
const userInfoUnsupported = "oidc: user info endpoint is not supported by this provider"
|
|
if err.Error() == userInfoUnsupported {
|
|
return nil
|
|
}
|
|
|
|
return httperr.Wrap(http.StatusInternalServerError, "could not get user info", err)
|
|
}
|
|
|
|
// The sub (subject) Claim MUST always be returned in the UserInfo Response.
|
|
//
|
|
// NOTE: Due to the possibility of token substitution attacks (see Section 16.11), the UserInfo Response is not
|
|
// guaranteed to be about the End-User identified by the sub (subject) element of the ID Token. The sub Claim in
|
|
// the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token; if they do not match,
|
|
// the UserInfo Response values MUST NOT be used.
|
|
//
|
|
// http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
|
|
if len(userInfo.Subject) == 0 || userInfo.Subject != idTokenSubject {
|
|
return httperr.Newf(http.StatusUnprocessableEntity, "userinfo 'sub' claim (%s) did not match id_token 'sub' claim (%s)", userInfo.Subject, idTokenSubject)
|
|
}
|
|
|
|
// merge existing claims with user info claims
|
|
if err := userInfo.Claims(&claims); err != nil {
|
|
return httperr.Wrap(http.StatusInternalServerError, "could not unmarshal user info claims", err)
|
|
}
|
|
|
|
return nil
|
|
}
|