2022-01-07 23:04:58 +00:00
// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
2020-11-30 20:54:11 +00:00
// SPDX-License-Identifier: Apache-2.0
// Package upstreamoidc implements an abstraction of upstream OIDC provider interactions.
package upstreamoidc
import (
"context"
2021-09-28 15:51:01 +00:00
"encoding/json"
2021-08-12 17:00:18 +00:00
"fmt"
2021-10-22 21:32:26 +00:00
"io"
2020-11-30 20:54:11 +00:00
"net/http"
"net/url"
2021-10-22 21:32:26 +00:00
"strings"
2021-12-14 00:40:13 +00:00
"time"
2020-11-30 20:54:11 +00:00
2021-01-20 17:54:44 +00:00
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
2020-11-30 20:54:11 +00:00
"golang.org/x/oauth2"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2021-10-08 22:48:21 +00:00
"k8s.io/apimachinery/pkg/types"
2021-09-28 15:51:01 +00:00
"k8s.io/apimachinery/pkg/util/sets"
2020-11-30 20:54:11 +00:00
"go.pinniped.dev/internal/httputil/httperr"
2021-01-09 19:27:35 +00:00
"go.pinniped.dev/internal/oidc"
2020-11-30 20:54:11 +00:00
"go.pinniped.dev/internal/oidc/provider"
2021-01-14 21:46:01 +00:00
"go.pinniped.dev/internal/plog"
2020-11-30 20:54:11 +00:00
"go.pinniped.dev/pkg/oidcclient/nonce"
2020-11-30 23:02:03 +00:00
"go.pinniped.dev/pkg/oidcclient/oidctypes"
2020-11-30 20:54:11 +00:00
"go.pinniped.dev/pkg/oidcclient/pkce"
)
2021-01-09 19:27:35 +00:00
func New ( config * oauth2 . Config , provider * coreosoidc . Provider , client * http . Client ) provider . UpstreamOIDCIdentityProviderI {
2020-12-02 16:27:20 +00:00
return & ProviderConfig { Config : config , Provider : provider , Client : client }
2020-11-30 23:14:57 +00:00
}
2020-11-30 20:54:11 +00:00
// ProviderConfig holds the active configuration of an upstream OIDC provider.
type ProviderConfig struct {
2022-01-05 18:31:38 +00:00
Name string
ResourceUID types . UID
UsernameClaim string
GroupsClaim string
Config * oauth2 . Config
Client * http . Client
AllowPasswordGrant bool
AllowAccessTokenBasedRefresh bool
AdditionalAuthcodeParams map [ string ] string
RevocationURL * url . URL // will commonly be nil: many providers do not offer this
Provider interface {
2021-01-09 19:27:35 +00:00
Verifier ( * coreosoidc . Config ) * coreosoidc . IDTokenVerifier
2021-09-28 15:29:20 +00:00
Claims ( v interface { } ) error
2021-01-09 19:27:35 +00:00
UserInfo ( ctx context . Context , tokenSource oauth2 . TokenSource ) ( * coreosoidc . UserInfo , error )
2020-11-30 20:54:11 +00:00
}
}
2021-10-13 19:31:20 +00:00
var _ provider . UpstreamOIDCIdentityProviderI = ( * ProviderConfig ) ( nil )
2021-10-08 22:48:21 +00:00
func ( p * ProviderConfig ) GetResourceUID ( ) types . UID {
return p . ResourceUID
}
2021-10-22 21:32:26 +00:00
func ( p * ProviderConfig ) GetRevocationURL ( ) * url . URL {
return p . RevocationURL
}
2021-10-08 22:48:21 +00:00
func ( p * ProviderConfig ) GetAdditionalAuthcodeParams ( ) map [ string ] string {
return p . AdditionalAuthcodeParams
}
2020-11-30 20:54:11 +00:00
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
}
2021-08-12 17:00:18 +00:00
func ( p * ProviderConfig ) AllowsPasswordGrant ( ) bool {
return p . AllowPasswordGrant
}
2022-01-05 18:31:38 +00:00
func ( p * ProviderConfig ) AllowsAccessTokenBasedRefresh ( ) bool {
return p . AllowAccessTokenBasedRefresh
}
2021-08-12 17:00:18 +00:00
func ( p * ProviderConfig ) PasswordCredentialsGrantAndValidateTokens ( ctx context . Context , username , password string ) ( * oidctypes . Token , error ) {
// Disallow this grant when requested.
if ! p . AllowPasswordGrant {
2021-08-16 21:27:40 +00:00
return nil , fmt . Errorf ( "resource owner password credentials grant is not allowed for this upstream provider according to its configuration" )
2021-08-12 17:00:18 +00:00
}
// 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.
2021-08-17 20:14:09 +00:00
const skipNonceValidation nonce . Nonce = ""
2021-12-16 20:53:49 +00:00
return p . ValidateTokenAndMergeWithUserInfo ( ctx , tok , skipNonceValidation , true )
2021-08-12 17:00:18 +00:00
}
2020-12-04 21:33:36 +00:00
func ( p * ProviderConfig ) ExchangeAuthcodeAndValidateTokens ( ctx context . Context , authcode string , pkceCodeVerifier pkce . Code , expectedIDTokenNonce nonce . Nonce , redirectURI string ) ( * oidctypes . Token , error ) {
2020-12-02 16:36:07 +00:00
tok , err := p . Config . Exchange (
2021-01-09 19:27:35 +00:00
coreosoidc . ClientContext ( ctx , p . Client ) ,
2020-12-02 16:36:07 +00:00
authcode ,
pkceCodeVerifier . Verifier ( ) ,
oauth2 . SetAuthURLParam ( "redirect_uri" , redirectURI ) ,
)
2020-11-30 20:54:11 +00:00
if err != nil {
2020-12-04 21:33:36 +00:00
return nil , err
2020-11-30 20:54:11 +00:00
}
2021-12-16 20:53:49 +00:00
return p . ValidateTokenAndMergeWithUserInfo ( ctx , tok , expectedIDTokenNonce , true )
2020-11-30 23:08:27 +00:00
}
2021-10-13 19:31:20 +00:00
func ( p * ProviderConfig ) PerformRefresh ( ctx context . Context , refreshToken string ) ( * oauth2 . Token , error ) {
2021-10-13 21:05:00 +00:00
// Use the provided HTTP client to benefit from its CA, proxy, and other settings.
httpClientContext := coreosoidc . ClientContext ( ctx , p . Client )
2021-10-13 19:31:20 +00:00
// 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.
2021-10-13 21:05:00 +00:00
return p . Config . TokenSource ( httpClientContext , & oauth2 . Token { RefreshToken : refreshToken } ) . Token ( )
2021-10-13 19:31:20 +00:00
}
2021-10-22 21:32:26 +00:00
// 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 {
2021-11-11 20:24:05 +00:00
plog . Trace ( "RevokeRefreshToken() was called but upstream provider has no available revocation endpoint" , "providerName" , p . Name )
2021-10-22 21:32:26 +00:00
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!
2021-11-11 20:24:05 +00:00
plog . Trace ( "RevokeRefreshToken() got 200 OK response from provider's revocation endpoint" , "providerName" , p . Name , "usedBasicAuth" , useBasicAuth )
2021-10-22 21:32:26 +00:00
return false , nil
case http . StatusBadRequest :
// Bad request might be due to bad client auth method. Try to detect that.
2021-11-11 20:24:05 +00:00
plog . Trace ( "RevokeRefreshToken() got 400 Bad Request response from provider's revocation endpoint" , "providerName" , p . Name , "usedBasicAuth" , useBasicAuth )
2021-10-22 21:32:26 +00:00
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
2021-11-11 20:24:05 +00:00
plog . Trace ( "RevokeRefreshToken()'s 400 Bad Request response from provider's revocation endpoint was type 'invalid_client'" , "providerName" , p . Name , "usedBasicAuth" , useBasicAuth )
2021-10-22 21:32:26 +00:00
return true , err
default :
// Any other error is probably not due to failed client auth.
2021-11-11 20:24:05 +00:00
plog . Trace ( "RevokeRefreshToken() got unexpected error response from provider's revocation endpoint" , "providerName" , p . Name , "usedBasicAuth" , useBasicAuth , "statusCode" , resp . StatusCode )
2021-10-22 21:32:26 +00:00
return false , fmt . Errorf ( "server responded with status %d" , resp . StatusCode )
}
}
2021-12-16 20:53:49 +00:00
// ValidateTokenAndMergeWithUserInfo will validate the ID token. It will also merge the claims from the userinfo endpoint response,
2021-10-13 19:31:20 +00:00
// if the provider offers the userinfo endpoint.
2021-12-16 20:53:49 +00:00
func ( p * ProviderConfig ) ValidateTokenAndMergeWithUserInfo ( ctx context . Context , tok * oauth2 . Token , expectedIDTokenNonce nonce . Nonce , requireIDToken bool ) ( * oidctypes . Token , error ) {
2021-12-14 00:40:13 +00:00
var validatedClaims = make ( map [ string ] interface { } )
var idTokenExpiry time . Time
// if we require the id token, make sure we have it.
// also, if it exists but wasn't required, still make sure it passes these checks.
2022-01-07 23:04:58 +00:00
idTokenExpiry , idTok , err := p . validateIDToken ( ctx , tok , expectedIDTokenNonce , validatedClaims , requireIDToken )
if err != nil {
return nil , err
2020-11-30 20:54:11 +00:00
}
2022-01-07 23:04:58 +00:00
2021-11-30 00:44:58 +00:00
idTokenSubject , _ := validatedClaims [ oidc . IDTokenSubjectClaim ] . ( string )
2021-12-14 00:40:13 +00:00
if len ( idTokenSubject ) > 0 || ! requireIDToken {
2022-01-12 19:19:43 +00:00
// only fetch userinfo if the ID token has a subject or if we are ignoring the id token completely.
// otherwise, defer to existing ID token validation
2021-12-14 00:40:13 +00:00
if err := p . maybeFetchUserInfoAndMergeClaims ( ctx , tok , validatedClaims , requireIDToken ) ; err != nil {
2021-11-30 00:44:58 +00:00
return nil , httperr . Wrap ( http . StatusInternalServerError , "could not fetch user info claims" , err )
}
2020-11-30 20:54:11 +00:00
}
2020-12-04 21:33:36 +00:00
return & oidctypes . Token {
2020-11-30 23:02:03 +00:00
AccessToken : & oidctypes . AccessToken {
2020-11-30 20:54:11 +00:00
Token : tok . AccessToken ,
Type : tok . TokenType ,
Expiry : metav1 . NewTime ( tok . Expiry ) ,
} ,
2020-11-30 23:02:03 +00:00
RefreshToken : & oidctypes . RefreshToken {
2020-11-30 20:54:11 +00:00
Token : tok . RefreshToken ,
} ,
2020-11-30 23:02:03 +00:00
IDToken : & oidctypes . IDToken {
2020-11-30 20:54:11 +00:00
Token : idTok ,
2021-12-14 00:40:13 +00:00
Expiry : metav1 . NewTime ( idTokenExpiry ) ,
2020-12-04 21:33:36 +00:00
Claims : validatedClaims ,
2020-11-30 20:54:11 +00:00
} ,
2020-12-04 21:33:36 +00:00
} , nil
2020-11-30 20:54:11 +00:00
}
2021-01-09 19:27:35 +00:00
2022-01-07 23:04:58 +00:00
func ( p * ProviderConfig ) validateIDToken ( ctx context . Context , tok * oauth2 . Token , expectedIDTokenNonce nonce . Nonce , validatedClaims map [ string ] interface { } , requireIDToken bool ) ( time . Time , string , error ) {
idTok , hasIDTok := tok . Extra ( "id_token" ) . ( string )
if ! hasIDTok && ! requireIDToken {
return time . Time { } , "" , nil // exit early
}
var idTokenExpiry time . Time
if ! hasIDTok {
return time . Time { } , "" , 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 time . Time { } , "" , httperr . Wrap ( http . StatusBadRequest , "received invalid ID token" , err )
}
if validated . AccessTokenHash != "" {
if err := validated . VerifyAccessToken ( tok . AccessToken ) ; err != nil {
return time . Time { } , "" , httperr . Wrap ( http . StatusBadRequest , "received invalid ID token" , err )
}
}
if expectedIDTokenNonce != "" {
if err := expectedIDTokenNonce . Validate ( validated ) ; err != nil {
return time . Time { } , "" , httperr . Wrap ( http . StatusBadRequest , "received ID token with invalid nonce" , err )
}
}
if err := validated . Claims ( & validatedClaims ) ; err != nil {
return time . Time { } , "" , httperr . Wrap ( http . StatusInternalServerError , "could not unmarshal id token claims" , err )
}
maybeLogClaims ( "claims from ID token" , p . Name , validatedClaims )
idTokenExpiry = validated . Expiry // keep track of the id token expiry if we have an id token. Otherwise, it'll just be the zero value.
return idTokenExpiry , idTok , nil
}
2021-12-14 00:40:13 +00:00
func ( p * ProviderConfig ) maybeFetchUserInfoAndMergeClaims ( ctx context . Context , tok * oauth2 . Token , claims map [ string ] interface { } , requireIDToken bool ) error {
2021-01-09 19:27:35 +00:00
idTokenSubject , _ := claims [ oidc . IDTokenSubjectClaim ] . ( string )
2022-01-07 23:04:58 +00:00
userInfo , err := p . maybeFetchUserInfo ( ctx , tok )
2021-11-30 00:44:58 +00:00
if err != nil {
return err
2021-09-28 15:29:20 +00:00
}
2021-11-30 00:44:58 +00:00
if userInfo == nil {
2021-09-28 15:29:20 +00:00
return nil
}
2021-01-09 19:27:35 +00:00
// 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
2021-12-16 20:53:49 +00:00
// If there is no ID token and it is not required, we must assume that the caller is performing other checks
// to ensure the subject is correct.
2021-12-14 00:40:13 +00:00
checkIDToken := requireIDToken || len ( idTokenSubject ) > 0
if checkIDToken && ( len ( userInfo . Subject ) == 0 || userInfo . Subject != idTokenSubject ) {
2021-01-09 19:27:35 +00:00
return httperr . Newf ( http . StatusUnprocessableEntity , "userinfo 'sub' claim (%s) did not match id_token 'sub' claim (%s)" , userInfo . Subject , idTokenSubject )
}
2021-12-16 20:53:49 +00:00
// keep track of the issuer from the ID token
2021-12-14 00:40:13 +00:00
idTokenIssuer := claims [ "iss" ]
2021-01-09 19:27:35 +00:00
// 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 )
}
2021-12-16 20:53:49 +00:00
// The OIDC spec for the UserInfo response does not make any guarantees about the iss claim's existence or validity:
2021-12-14 00:40:13 +00:00
// "If signed, the UserInfo Response SHOULD contain the Claims iss (issuer) and aud (audience) as members. The iss value SHOULD be the OP's Issuer Identifier URL."
// See https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
// So we just ignore it and use it the version from the id token, which has stronger guarantees.
delete ( claims , "iss" )
if idTokenIssuer != nil {
claims [ "iss" ] = idTokenIssuer
}
2021-01-09 19:27:35 +00:00
2021-09-28 15:51:01 +00:00
maybeLogClaims ( "claims from ID token and userinfo" , p . Name , claims )
2021-01-09 19:27:35 +00:00
return nil
}
2021-09-28 15:51:01 +00:00
2022-01-07 23:04:58 +00:00
func ( p * ProviderConfig ) maybeFetchUserInfo ( ctx context . Context , tok * oauth2 . Token ) ( * coreosoidc . UserInfo , error ) {
2021-11-30 00:44:58 +00:00
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 nil , 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 , nil
}
userInfo , err := p . Provider . UserInfo ( coreosoidc . ClientContext ( ctx , p . Client ) , oauth2 . StaticTokenSource ( tok ) )
if err != nil {
return nil , httperr . Wrap ( http . StatusInternalServerError , "could not get user info" , err )
}
return userInfo , nil
}
2021-09-28 15:51:01 +00:00
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
}
}