ContainerImage.Pinniped/internal/upstreamoidc/upstreamoidc.go
Ryan Richard d0ced1fd74 WIP towards revoking upstream refresh tokens during GC
- Discover the revocation endpoint of the upstream provider in
  oidc_upstream_watcher.go and save it into the cache for future use
  by the garbage collector controller
- Adds RevokeRefreshToken to UpstreamOIDCIdentityProviderI
- Implements the production version of RevokeRefreshToken
- Implements test doubles for RevokeRefreshToken for future use in
  garbage collector's unit tests
- Prefactors the crud and session storage types for future use in the
  garbage collector controller
- See remaining TODOs in garbage_collector.go
2021-10-22 14:32:26 -07:00

342 lines
12 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"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/sets"
"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
ResourceUID types.UID
UsernameClaim string
GroupsClaim string
Config *oauth2.Config
Client *http.Client
AllowPasswordGrant bool
AdditionalAuthcodeParams map[string]string
RevocationURL *url.URL // will commonly be nil: many providers do not offer this
Provider interface {
Verifier(*coreosoidc.Config) *coreosoidc.IDTokenVerifier
Claims(v interface{}) error
UserInfo(ctx context.Context, tokenSource oauth2.TokenSource) (*coreosoidc.UserInfo, error)
}
}
var _ provider.UpstreamOIDCIdentityProviderI = (*ProviderConfig)(nil)
func (p *ProviderConfig) GetResourceUID() types.UID {
return p.ResourceUID
}
func (p *ProviderConfig) GetRevocationURL() *url.URL {
return p.RevocationURL
}
func (p *ProviderConfig) GetAdditionalAuthcodeParams() map[string]string {
return p.AdditionalAuthcodeParams
}
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 credentials 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.
const 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) PerformRefresh(ctx context.Context, refreshToken string) (*oauth2.Token, error) {
// Use the provided HTTP client to benefit from its CA, proxy, and other settings.
httpClientContext := coreosoidc.ClientContext(ctx, p.Client)
// Create a TokenSource without an access token, so it thinks that a refresh is immediately required.
// Then ask it for the tokens to cause it to perform the refresh and return the results.
return p.Config.TokenSource(httpClientContext, &oauth2.Token{RefreshToken: refreshToken}).Token()
}
// RevokeRefreshToken will attempt to revoke the given token, if the provider has a revocation endpoint.
func (p *ProviderConfig) RevokeRefreshToken(ctx context.Context, refreshToken string) error {
if p.RevocationURL == nil {
return nil
}
// First try using client auth in the request params.
tryAnotherClientAuthMethod, err := p.tryRevokeRefreshToken(ctx, refreshToken, false)
if tryAnotherClientAuthMethod {
// Try again using basic auth this time. Overwrite the first client auth error,
// which isn't useful anymore when retrying.
_, err = p.tryRevokeRefreshToken(ctx, refreshToken, true)
}
return err
}
// tryRevokeRefreshToken will call the revocation endpoint using either basic auth or by including
// client auth in the request params. It will return an error when the request failed. If the
// request failed for a reason that might be due to bad client auth, then it will return true
// for the tryAnotherClientAuthMethod return value, indicating that it might be worth trying
// again using the other client auth method.
// RFC 7009 defines how to make a revocation request and how to interpret the response.
// See https://datatracker.ietf.org/doc/html/rfc7009#section-2.1 for details.
func (p *ProviderConfig) tryRevokeRefreshToken(
ctx context.Context,
refreshToken string,
useBasicAuth bool,
) (tryAnotherClientAuthMethod bool, err error) {
clientID := p.Config.ClientID
clientSecret := p.Config.ClientSecret
// Use the provided HTTP client to benefit from its CA, proxy, and other settings.
httpClient := p.Client
params := url.Values{
"token": []string{refreshToken},
"token_type_hint": []string{"refresh_token"},
}
if !useBasicAuth {
params["client_id"] = []string{clientID}
params["client_secret"] = []string{clientSecret}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.RevocationURL.String(), strings.NewReader(params.Encode()))
if err != nil {
// This shouldn't really happen since we already know that the method and URL are legal.
return false, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if useBasicAuth {
req.SetBasicAuth(clientID, clientSecret)
}
resp, err := httpClient.Do(req)
if err != nil {
// Couldn't connect to the server or some similar error.
return false, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// Success!
return false, nil
case http.StatusBadRequest:
// Bad request might be due to bad client auth method. Try to detect that.
body, err := io.ReadAll(resp.Body)
if err != nil {
return false,
fmt.Errorf("error reading response body on response with status code %d: %w", resp.StatusCode, err)
}
var parsedResp struct {
ErrorType string `json:"error"`
ErrorDescription string `json:"error_description"`
}
bodyStr := strings.TrimSpace(string(body)) // trimmed for logging purposes
err = json.Unmarshal(body, &parsedResp)
if err != nil {
return false,
fmt.Errorf("error parsing response body %q on response with status code %d: %w", bodyStr, resp.StatusCode, err)
}
err = fmt.Errorf("server responded with status %d with body: %s", resp.StatusCode, bodyStr)
if parsedResp.ErrorType != "invalid_client" {
// Got an error unrelated to client auth, so not worth trying again.
return false, err
}
// Got an "invalid_client" response, which might mean client auth failed, so it may be worth trying again
// using another client auth method. See https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
return true, err
default:
// Any other error is probably not due to failed client auth.
return false, fmt.Errorf("server responded with status %d", resp.StatusCode)
}
}
// ValidateToken will validate the ID token. It will also merge the claims from the userinfo endpoint response,
// if the provider offers the userinfo endpoint.
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)
}
maybeLogClaims("claims from ID token", p.Name, validatedClaims)
if err := p.maybeFetchUserInfoAndMergeClaims(ctx, tok, validatedClaims); err != nil {
return nil, httperr.Wrap(http.StatusInternalServerError, "could not fetch user info claims", err)
}
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) maybeFetchUserInfoAndMergeClaims(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
}
providerJSON := &struct {
UserInfoURL string `json:"userinfo_endpoint"`
}{}
if err := p.Provider.Claims(providerJSON); err != nil {
// this should never happen because we should have already parsed these claims at an earlier stage
return httperr.Wrap(http.StatusInternalServerError, "could not unmarshal discovery JSON", err)
}
// implementing the user info endpoint is not required, skip this logic when it is absent
if len(providerJSON.UserInfoURL) == 0 {
return nil
}
userInfo, err := p.Provider.UserInfo(coreosoidc.ClientContext(ctx, p.Client), oauth2.StaticTokenSource(tok))
if err != 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)
}
maybeLogClaims("claims from ID token and userinfo", p.Name, claims)
return nil
}
func maybeLogClaims(msg, name string, claims map[string]interface{}) {
if plog.Enabled(plog.LevelAll) { // log keys and values at all level
data, _ := json.Marshal(claims) // nothing we can do if it fails, but it really never should
plog.Info(msg, "providerName", name, "claims", string(data))
return
}
if plog.Enabled(plog.LevelDebug) { // log keys at debug level
keys := sets.StringKeySet(claims).List() // note: this is only safe because the compiler asserts that claims is a map[string]<anything>
plog.Info(msg, "providerName", name, "keys", keys)
return
}
}