Merge remote-tracking branch 'upstream/callback-endpoint' into token-endpoint

This commit is contained in:
Andrew Keesler
2020-12-03 11:14:37 -05:00
56 changed files with 4393 additions and 1542 deletions
+43 -71
View File
@@ -9,14 +9,13 @@ import (
"net/http"
"time"
"github.com/gorilla/securecookie"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/token/jwt"
"golang.org/x/oauth2"
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/csrftoken"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/plog"
@@ -24,42 +23,15 @@ import (
"go.pinniped.dev/pkg/oidcclient/pkce"
)
const (
// Just in case we need to make a breaking change to the format of the upstream state param,
// we are including a format version number. This gives the opportunity for a future version of Pinniped
// to have the consumer of this format decide to reject versions that it doesn't understand.
upstreamStateParamFormatVersion = "1"
// The `name` passed to the encoder for encoding the upstream state param value. This name is short
// because it will be encoded into the upstream state param value and we're trying to keep that small.
upstreamStateParamEncodingName = "s"
// The name of the browser cookie which shall hold our CSRF value.
// `__Host` prefix has a special meaning. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Cookie_prefixes
csrfCookieName = "__Host-pinniped-csrf"
// The `name` passed to the encoder for encoding and decoding the CSRF cookie contents.
csrfCookieEncodingName = "csrf"
)
type IDPListGetter interface {
GetIDPList() []provider.UpstreamOIDCIdentityProvider
}
// This is the encoding side of the securecookie.Codec interface.
type Encoder interface {
Encode(name string, value interface{}) (string, error)
}
func NewHandler(
issuer string,
idpListGetter IDPListGetter,
downstreamIssuer string,
idpListGetter oidc.IDPListGetter,
oauthHelper fosite.OAuth2Provider,
generateCSRF func() (csrftoken.CSRFToken, error),
generatePKCE func() (pkce.Code, error),
generateNonce func() (nonce.Nonce, error),
upstreamStateEncoder Encoder,
cookieCodec securecookie.Codec,
upstreamStateEncoder oidc.Encoder,
cookieCodec oidc.Codec,
) http.Handler {
return httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
if r.Method != http.MethodPost && r.Method != http.MethodGet {
@@ -69,11 +41,7 @@ func NewHandler(
return httperr.Newf(http.StatusMethodNotAllowed, "%s (try GET or POST)", r.Method)
}
csrfFromCookie, err := readCSRFCookie(r, cookieCodec)
if err != nil {
plog.InfoErr("error reading CSRF cookie", err)
return err
}
csrfFromCookie := readCSRFCookie(r, cookieCodec)
authorizeRequester, err := oauthHelper.NewAuthorizeRequest(r.Context(), r)
if err != nil {
@@ -116,15 +84,22 @@ func NewHandler(
}
upstreamOAuthConfig := oauth2.Config{
ClientID: upstreamIDP.ClientID,
ClientID: upstreamIDP.GetClientID(),
Endpoint: oauth2.Endpoint{
AuthURL: upstreamIDP.AuthorizationURL.String(),
AuthURL: upstreamIDP.GetAuthorizationURL().String(),
},
RedirectURL: fmt.Sprintf("%s/callback/%s", issuer, upstreamIDP.Name),
Scopes: upstreamIDP.Scopes,
RedirectURL: fmt.Sprintf("%s/callback", downstreamIssuer),
Scopes: upstreamIDP.GetScopes(),
}
encodedStateParamValue, err := upstreamStateParam(authorizeRequester, nonceValue, csrfValue, pkceValue, upstreamStateEncoder)
encodedStateParamValue, err := upstreamStateParam(
authorizeRequester,
upstreamIDP.GetName(),
nonceValue,
csrfValue,
pkceValue,
upstreamStateEncoder,
)
if err != nil {
plog.Error("authorize upstream state param error", err)
return err
@@ -154,20 +129,23 @@ func NewHandler(
})
}
func readCSRFCookie(r *http.Request, codec securecookie.Codec) (csrftoken.CSRFToken, error) {
receivedCSRFCookie, err := r.Cookie(csrfCookieName)
func readCSRFCookie(r *http.Request, codec oidc.Codec) csrftoken.CSRFToken {
receivedCSRFCookie, err := r.Cookie(oidc.CSRFCookieName)
if err != nil {
// Error means that the cookie was not found
return "", nil
return ""
}
var csrfFromCookie csrftoken.CSRFToken
err = codec.Decode(csrfCookieEncodingName, receivedCSRFCookie.Value, &csrfFromCookie)
err = codec.Decode(oidc.CSRFCookieEncodingName, receivedCSRFCookie.Value, &csrfFromCookie)
if err != nil {
return "", httperr.Wrap(http.StatusUnprocessableEntity, "error reading CSRF cookie", err)
// We can ignore any errors and just make a new cookie. Hopefully this will
// make the user experience better if, for example, the server rotated
// cookie signing keys and then a user submitted a very old cookie.
return ""
}
return csrfFromCookie, nil
return csrfFromCookie
}
func grantOpenIDScopeIfRequested(authorizeRequester fosite.AuthorizeRequester) {
@@ -178,7 +156,7 @@ func grantOpenIDScopeIfRequested(authorizeRequester fosite.AuthorizeRequester) {
}
}
func chooseUpstreamIDP(idpListGetter IDPListGetter) (*provider.UpstreamOIDCIdentityProvider, error) {
func chooseUpstreamIDP(idpListGetter oidc.IDPListGetter) (provider.UpstreamOIDCIdentityProviderI, error) {
allUpstreamIDPs := idpListGetter.GetIDPList()
if len(allUpstreamIDPs) == 0 {
return nil, httperr.New(
@@ -191,7 +169,7 @@ func chooseUpstreamIDP(idpListGetter IDPListGetter) (*provider.UpstreamOIDCIdent
"Too many upstream providers are configured (support for multiple upstreams is not yet implemented)",
)
}
return &allUpstreamIDPs[0], nil
return allUpstreamIDPs[0], nil
}
func generateValues(
@@ -214,48 +192,42 @@ func generateValues(
return csrfValue, nonceValue, pkceValue, nil
}
// Keep the JSON to a minimal size because the upstream provider could impose size limitations on the state param.
type upstreamStateParamData struct {
AuthParams string `json:"p"`
Nonce nonce.Nonce `json:"n"`
CSRFToken csrftoken.CSRFToken `json:"c"`
PKCECode pkce.Code `json:"k"`
StateParamFormatVersion string `json:"v"`
}
func upstreamStateParam(
authorizeRequester fosite.AuthorizeRequester,
upstreamName string,
nonceValue nonce.Nonce,
csrfValue csrftoken.CSRFToken,
pkceValue pkce.Code,
encoder Encoder,
encoder oidc.Encoder,
) (string, error) {
stateParamData := upstreamStateParamData{
AuthParams: authorizeRequester.GetRequestForm().Encode(),
Nonce: nonceValue,
CSRFToken: csrfValue,
PKCECode: pkceValue,
StateParamFormatVersion: upstreamStateParamFormatVersion,
stateParamData := oidc.UpstreamStateParamData{
AuthParams: authorizeRequester.GetRequestForm().Encode(),
UpstreamName: upstreamName,
Nonce: nonceValue,
CSRFToken: csrfValue,
PKCECode: pkceValue,
FormatVersion: oidc.UpstreamStateParamFormatVersion,
}
encodedStateParamValue, err := encoder.Encode(upstreamStateParamEncodingName, stateParamData)
encodedStateParamValue, err := encoder.Encode(oidc.UpstreamStateParamEncodingName, stateParamData)
if err != nil {
return "", httperr.Wrap(http.StatusInternalServerError, "error encoding upstream state param", err)
}
return encodedStateParamValue, nil
}
func addCSRFSetCookieHeader(w http.ResponseWriter, csrfValue csrftoken.CSRFToken, codec securecookie.Codec) error {
encodedCSRFValue, err := codec.Encode(csrfCookieEncodingName, csrfValue)
func addCSRFSetCookieHeader(w http.ResponseWriter, csrfValue csrftoken.CSRFToken, codec oidc.Codec) error {
encodedCSRFValue, err := codec.Encode(oidc.CSRFCookieEncodingName, csrfValue)
if err != nil {
return httperr.Wrap(http.StatusInternalServerError, "error encoding CSRF cookie", err)
}
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Name: oidc.CSRFCookieName,
Value: encodedCSRFValue,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: true,
Path: "/",
})
return nil
+120 -117
View File
@@ -21,6 +21,7 @@ import (
"go.pinniped.dev/internal/here"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/csrftoken"
"go.pinniped.dev/internal/oidc/oidctestutil"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/pkg/oidcclient/nonce"
"go.pinniped.dev/pkg/oidcclient/pkce"
@@ -28,6 +29,7 @@ import (
func TestAuthorizationEndpoint(t *testing.T) {
const (
downstreamIssuer = "https://my-downstream-issuer.com/some-path"
downstreamRedirectURI = "http://127.0.0.1/callback"
downstreamRedirectURIWithDifferentPort = "http://127.0.0.1:42/callback"
)
@@ -113,21 +115,19 @@ func TestAuthorizationEndpoint(t *testing.T) {
upstreamAuthURL, err := url.Parse("https://some-upstream-idp:8443/auth")
require.NoError(t, err)
upstreamOIDCIdentityProvider := provider.UpstreamOIDCIdentityProvider{
upstreamOIDCIdentityProvider := oidctestutil.TestUpstreamOIDCIdentityProvider{
Name: "some-idp",
ClientID: "some-client-id",
AuthorizationURL: *upstreamAuthURL,
Scopes: []string{"scope1", "scope2"},
}
issuer := "https://my-issuer.com/some-path"
// Configure fosite the same way that the production code would, except use in-memory storage.
// Configure fosite the same way that the production code would, using NullStorage to turn off storage.
oauthStore := oidc.NullStorage{}
hmacSecret := []byte("some secret - must have at least 32 bytes")
var signingKeyIsUnused *ecdsa.PrivateKey
require.GreaterOrEqual(t, len(hmacSecret), 32, "fosite requires that hmac secrets have at least 32 bytes")
oauthHelper := oidc.FositeOauth2Helper(issuer, oauthStore, hmacSecret, signingKeyIsUnused)
oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecret, signingKeyIsUnused)
happyCSRF := "test-csrf"
happyPKCE := "test-pkce"
@@ -206,14 +206,19 @@ func TestAuthorizationEndpoint(t *testing.T) {
return pathWithQuery("/some/path", modifiedHappyGetRequestQueryMap(queryOverrides))
}
expectedUpstreamStateParam := func(queryOverrides map[string]string, csrfValueOverride string) string {
expectedUpstreamStateParam := func(queryOverrides map[string]string, csrfValueOverride, upstreamNameOverride string) string {
csrf := happyCSRF
if csrfValueOverride != "" {
csrf = csrfValueOverride
}
upstreamName := upstreamOIDCIdentityProvider.Name
if upstreamNameOverride != "" {
upstreamName = upstreamNameOverride
}
encoded, err := happyStateEncoder.Encode("s",
expectedUpstreamStateParamFormat{
oidctestutil.ExpectedUpstreamStateParamFormat{
P: encodeQuery(modifiedHappyGetRequestQueryMap(queryOverrides)),
U: upstreamName,
N: happyNonce,
C: csrf,
K: happyPKCE,
@@ -234,7 +239,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
"nonce": happyNonce,
"code_challenge": expectedUpstreamCodeChallenge,
"code_challenge_method": "S256",
"redirect_uri": issuer + "/callback/some-idp",
"redirect_uri": downstreamIssuer + "/callback",
})
}
@@ -250,8 +255,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
generateCSRF func() (csrftoken.CSRFToken, error)
generatePKCE func() (pkce.Code, error)
generateNonce func() (nonce.Nonce, error)
stateEncoder securecookie.Codec
cookieEncoder securecookie.Codec
stateEncoder oidc.Codec
cookieEncoder oidc.Codec
method string
path string
contentType string
@@ -271,8 +276,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
tests := []testCase{
{
name: "happy path using GET without a CSRF cookie",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -283,14 +288,14 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantStatus: http.StatusFound,
wantContentType: "text/html; charset=utf-8",
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocation(expectedUpstreamStateParam(nil, "")),
wantLocationHeader: expectedRedirectLocation(expectedUpstreamStateParam(nil, "", "")),
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "happy path using GET with a CSRF cookie",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -298,17 +303,17 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: happyGetRequestPath,
csrfCookie: "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue,
csrfCookie: "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue + " ",
wantStatus: http.StatusFound,
wantContentType: "text/html; charset=utf-8",
wantLocationHeader: expectedRedirectLocation(expectedUpstreamStateParam(nil, incomingCookieCSRFValue)),
wantLocationHeader: expectedRedirectLocation(expectedUpstreamStateParam(nil, incomingCookieCSRFValue, "")),
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "happy path using POST",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -322,13 +327,33 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantContentType: "",
wantBodyString: "",
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocation(expectedUpstreamStateParam(nil, "")),
wantLocationHeader: expectedRedirectLocation(expectedUpstreamStateParam(nil, "", "")),
wantUpstreamStateParamInLocationHeader: true,
},
{
name: "error while decoding CSRF cookie just generates a new cookie and succeeds as usual",
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: happyGetRequestPath,
csrfCookie: "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped",
wantStatus: http.StatusFound,
wantContentType: "text/html; charset=utf-8",
// Generated a new CSRF cookie and set it in the response.
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocation(expectedUpstreamStateParam(nil, "", "")),
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "happy path when downstream redirect uri matches what is configured for client except for the port number",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -343,14 +368,14 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocation(expectedUpstreamStateParam(map[string]string{
"redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client
}, "")),
}, "", "")),
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "downstream redirect uri does not match what is configured for client",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -366,8 +391,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "downstream client does not exist",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -381,8 +406,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "response type is unsupported",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -397,8 +422,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "downstream scopes do not match what is configured for client",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -413,8 +438,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "missing response type in request",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -429,8 +454,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "missing client id in request",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -444,8 +469,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "missing PKCE code_challenge in request", // See https://tools.ietf.org/html/rfc7636#section-4.4.1
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -460,8 +485,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "invalid value for PKCE code_challenge_method in request", // https://tools.ietf.org/html/rfc7636#section-4.3
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -476,8 +501,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "when PKCE code_challenge_method in request is `plain`", // https://tools.ietf.org/html/rfc7636#section-4.3
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -492,8 +517,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "missing PKCE code_challenge_method in request", // See https://tools.ietf.org/html/rfc7636#section-4.4.1
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -510,8 +535,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
// This is just one of the many OIDC validations run by fosite. This test is to ensure that we are running
// through that part of the fosite library.
name: "prompt param is not allowed to have none and another legal value at the same time",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -526,8 +551,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "OIDC validations are skipped when the openid scope was not requested",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -540,15 +565,15 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantContentType: "text/html; charset=utf-8",
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocation(expectedUpstreamStateParam(
map[string]string{"prompt": "none login", "scope": "email"}, "",
map[string]string{"prompt": "none login", "scope": "email"}, "", "",
)),
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "state does not have enough entropy",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -563,8 +588,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "error while encoding upstream state param",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -578,8 +603,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "error while encoding CSRF cookie value for new cookie",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -593,8 +618,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "error while generating CSRF token",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: func() (csrftoken.CSRFToken, error) { return "", fmt.Errorf("some csrf generator error") },
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
@@ -608,8 +633,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "error while generating nonce",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: func() (nonce.Nonce, error) { return "", fmt.Errorf("some nonce generator error") },
@@ -623,8 +648,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "error while generating PKCE",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: func() (pkce.Code, error) { return "", fmt.Errorf("some PKCE generator error") },
generateNonce: happyNonceGenerator,
@@ -636,26 +661,10 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantContentType: "text/plain; charset=utf-8",
wantBodyString: "Internal Server Error: error generating PKCE param\n",
},
{
name: "error while decoding CSRF cookie",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: happyGetRequestPath,
csrfCookie: "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped",
wantStatus: http.StatusUnprocessableEntity,
wantContentType: "text/plain; charset=utf-8",
wantBodyString: "Unprocessable Entity: error reading CSRF cookie\n",
},
{
name: "no upstream providers are configured",
issuer: issuer,
idpListGetter: newIDPListGetter(), // empty
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(), // empty
method: http.MethodGet,
path: happyGetRequestPath,
wantStatus: http.StatusUnprocessableEntity,
@@ -664,8 +673,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "too many upstream providers are configured",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider, upstreamOIDCIdentityProvider), // more than one not allowed
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider, &upstreamOIDCIdentityProvider), // more than one not allowed
method: http.MethodGet,
path: happyGetRequestPath,
wantStatus: http.StatusUnprocessableEntity,
@@ -674,8 +683,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "PUT is a bad method",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
method: http.MethodPut,
path: "/some/path",
wantStatus: http.StatusMethodNotAllowed,
@@ -684,8 +693,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "PATCH is a bad method",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
method: http.MethodPatch,
path: "/some/path",
wantStatus: http.StatusMethodNotAllowed,
@@ -694,8 +703,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
{
name: "DELETE is a bad method",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
issuer: downstreamIssuer,
idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider),
method: http.MethodDelete,
path: "/some/path",
wantStatus: http.StatusMethodNotAllowed,
@@ -712,6 +721,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
}
rsp := httptest.NewRecorder()
subject.ServeHTTP(rsp, req)
t.Logf("response: %#v", rsp)
t.Logf("response body: %q", rsp.Body.String())
require.Equal(t, test.wantStatus, rsp.Code)
requireEqualContentType(t, rsp.Header().Get("Content-Type"), test.wantContentType)
@@ -742,7 +753,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
if test.wantCSRFValueInCookieHeader != "" {
require.Len(t, rsp.Header().Values("Set-Cookie"), 1)
actualCookie := rsp.Header().Get("Set-Cookie")
regex := regexp.MustCompile("__Host-pinniped-csrf=([^;]+); HttpOnly; Secure; SameSite=Strict")
regex := regexp.MustCompile("__Host-pinniped-csrf=([^;]+); Path=/; HttpOnly; Secure; SameSite=Strict")
submatches := regex.FindStringSubmatch(actualCookie)
require.Len(t, submatches, 2)
captured := submatches[1]
@@ -772,13 +783,13 @@ func TestAuthorizationEndpoint(t *testing.T) {
runOneTestCase(t, test, subject)
// Call the setter to change the upstream IDP settings.
newProviderSettings := provider.UpstreamOIDCIdentityProvider{
newProviderSettings := oidctestutil.TestUpstreamOIDCIdentityProvider{
Name: "some-other-idp",
ClientID: "some-other-client-id",
AuthorizationURL: *upstreamAuthURL,
Scopes: []string{"other-scope1", "other-scope2"},
}
test.idpListGetter.SetIDPList([]provider.UpstreamOIDCIdentityProvider{newProviderSettings})
test.idpListGetter.SetIDPList([]provider.UpstreamOIDCIdentityProviderI{provider.UpstreamOIDCIdentityProviderI(&newProviderSettings)})
// Update the expectations of the test case to match the new upstream IDP settings.
test.wantLocationHeader = urlWithQuery(upstreamAuthURL.String(),
@@ -787,11 +798,11 @@ func TestAuthorizationEndpoint(t *testing.T) {
"access_type": "offline",
"scope": "other-scope1 other-scope2",
"client_id": "some-other-client-id",
"state": expectedUpstreamStateParam(nil, ""),
"state": expectedUpstreamStateParam(nil, "", newProviderSettings.Name),
"nonce": happyNonce,
"code_challenge": expectedUpstreamCodeChallenge,
"code_challenge_method": "S256",
"redirect_uri": issuer + "/callback/some-other-idp",
"redirect_uri": downstreamIssuer + "/callback",
},
)
test.wantBodyString = fmt.Sprintf(`<a href="%s">Found</a>.%s`,
@@ -807,20 +818,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
})
}
// Declare a separate type from the production code to ensure that the state param's contents was serialized
// in the format that we expect, with the json keys that we expect, etc. This also ensure that the order of
// the serialized fields is the same, which doesn't really matter expect that we can make simpler equality
// assertions about the redirect URL in this test.
type expectedUpstreamStateParamFormat struct {
P string `json:"p"`
N string `json:"n"`
C string `json:"c"`
K string `json:"k"`
V string `json:"v"`
}
type errorReturningEncoder struct {
securecookie.Codec
oidc.Codec
}
func (*errorReturningEncoder) Encode(_ string, _ interface{}) (string, error) {
@@ -843,7 +842,7 @@ func requireEqualContentType(t *testing.T, actual string, expected string) {
require.Equal(t, actualContentTypeParams, expectedContentTypeParams)
}
func requireEqualDecodedStateParams(t *testing.T, actualURL string, expectedURL string, stateParamDecoder securecookie.Codec) {
func requireEqualDecodedStateParams(t *testing.T, actualURL string, expectedURL string, stateParamDecoder oidc.Codec) {
t.Helper()
actualLocationURL, err := url.Parse(actualURL)
require.NoError(t, err)
@@ -852,13 +851,13 @@ func requireEqualDecodedStateParams(t *testing.T, actualURL string, expectedURL
expectedQueryStateParam := expectedLocationURL.Query().Get("state")
require.NotEmpty(t, expectedQueryStateParam)
var expectedDecodedStateParam expectedUpstreamStateParamFormat
var expectedDecodedStateParam oidctestutil.ExpectedUpstreamStateParamFormat
err = stateParamDecoder.Decode("s", expectedQueryStateParam, &expectedDecodedStateParam)
require.NoError(t, err)
actualQueryStateParam := actualLocationURL.Query().Get("state")
require.NotEmpty(t, actualQueryStateParam)
var actualDecodedStateParam expectedUpstreamStateParamFormat
var actualDecodedStateParam oidctestutil.ExpectedUpstreamStateParamFormat
err = stateParamDecoder.Decode("s", actualQueryStateParam, &actualDecodedStateParam)
require.NoError(t, err)
@@ -871,10 +870,20 @@ func requireEqualURLs(t *testing.T, actualURL string, expectedURL string, ignore
require.NoError(t, err)
expectedLocationURL, err := url.Parse(expectedURL)
require.NoError(t, err)
require.Equal(t, expectedLocationURL.Scheme, actualLocationURL.Scheme)
require.Equal(t, expectedLocationURL.User, actualLocationURL.User)
require.Equal(t, expectedLocationURL.Host, actualLocationURL.Host)
require.Equal(t, expectedLocationURL.Path, actualLocationURL.Path)
require.Equal(t, expectedLocationURL.Scheme, actualLocationURL.Scheme,
"schemes were not equal: expected %s but got %s", expectedURL, actualURL,
)
require.Equal(t, expectedLocationURL.User, actualLocationURL.User,
"users were not equal: expected %s but got %s", expectedURL, actualURL,
)
require.Equal(t, expectedLocationURL.Host, actualLocationURL.Host,
"hosts were not equal: expected %s but got %s", expectedURL, actualURL,
)
require.Equal(t, expectedLocationURL.Path, actualLocationURL.Path,
"paths were not equal: expected %s but got %s", expectedURL, actualURL,
)
expectedLocationQuery := expectedLocationURL.Query()
actualLocationQuery := actualLocationURL.Query()
@@ -886,9 +895,3 @@ func requireEqualURLs(t *testing.T, actualURL string, expectedURL string, ignore
}
require.Equal(t, expectedLocationQuery, actualLocationQuery)
}
func newIDPListGetter(upstreamOIDCIdentityProviders ...provider.UpstreamOIDCIdentityProvider) provider.DynamicUpstreamIDPProvider {
idpProvider := provider.NewDynamicUpstreamIDPProvider()
idpProvider.SetIDPList(upstreamOIDCIdentityProviders)
return idpProvider
}
+306
View File
@@ -0,0 +1,306 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package callback provides a handler for the OIDC callback endpoint.
package callback
import (
"crypto/subtle"
"fmt"
"net/http"
"net/url"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/token/jwt"
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/csrftoken"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/plog"
)
const (
// The name of the issuer claim specified in the OIDC spec.
idTokenIssuerClaim = "iss"
// The name of the subject claim specified in the OIDC spec.
idTokenSubjectClaim = "sub"
// defaultUpstreamUsernameClaim is what we will use to extract the username from an upstream OIDC
// ID token if the upstream OIDC IDP did not tell us to use another claim.
defaultUpstreamUsernameClaim = idTokenSubjectClaim
// downstreamGroupsClaim is what we will use to encode the groups in the downstream OIDC ID token
// information.
downstreamGroupsClaim = "groups"
)
func NewHandler(
idpListGetter oidc.IDPListGetter,
oauthHelper fosite.OAuth2Provider,
stateDecoder, cookieDecoder oidc.Decoder,
redirectURI string,
) http.Handler {
return httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
state, err := validateRequest(r, stateDecoder, cookieDecoder)
if err != nil {
return err
}
upstreamIDPConfig := findUpstreamIDPConfig(state.UpstreamName, idpListGetter)
if upstreamIDPConfig == nil {
plog.Warning("upstream provider not found")
return httperr.New(http.StatusUnprocessableEntity, "upstream provider not found")
}
downstreamAuthParams, err := url.ParseQuery(state.AuthParams)
if err != nil {
plog.Error("error reading state downstream auth params", err)
return httperr.New(http.StatusBadRequest, "error reading state downstream auth params")
}
// Recreate enough of the original authorize request so we can pass it to NewAuthorizeRequest().
reconstitutedAuthRequest := &http.Request{Form: downstreamAuthParams}
authorizeRequester, err := oauthHelper.NewAuthorizeRequest(r.Context(), reconstitutedAuthRequest)
if err != nil {
plog.Error("error using state downstream auth params", err)
return httperr.New(http.StatusBadRequest, "error using state downstream auth params")
}
// Grant the openid scope only if it was requested.
grantOpenIDScopeIfRequested(authorizeRequester)
_, idTokenClaims, err := upstreamIDPConfig.ExchangeAuthcodeAndValidateTokens(
r.Context(),
authcode(r),
state.PKCECode,
state.Nonce,
redirectURI,
)
if err != nil {
plog.WarningErr("error exchanging and validating upstream tokens", err, "upstreamName", upstreamIDPConfig.GetName())
return httperr.New(http.StatusBadGateway, "error exchanging and validating upstream tokens")
}
username, err := getUsernameFromUpstreamIDToken(upstreamIDPConfig, idTokenClaims)
if err != nil {
return err
}
groups, err := getGroupsFromUpstreamIDToken(upstreamIDPConfig, idTokenClaims)
if err != nil {
return err
}
openIDSession := makeDownstreamSession(username, groups)
authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession)
if err != nil {
plog.WarningErr("error while generating and saving authcode", err, "upstreamName", upstreamIDPConfig.GetName())
return httperr.Wrap(http.StatusInternalServerError, "error while generating and saving authcode", err)
}
oauthHelper.WriteAuthorizeResponse(w, authorizeRequester, authorizeResponder)
return nil
})
}
func authcode(r *http.Request) string {
return r.FormValue("code")
}
func validateRequest(r *http.Request, stateDecoder, cookieDecoder oidc.Decoder) (*oidc.UpstreamStateParamData, error) {
if r.Method != http.MethodGet {
return nil, httperr.Newf(http.StatusMethodNotAllowed, "%s (try GET)", r.Method)
}
csrfValue, err := readCSRFCookie(r, cookieDecoder)
if err != nil {
plog.InfoErr("error reading CSRF cookie", err)
return nil, err
}
if authcode(r) == "" {
plog.Info("code param not found")
return nil, httperr.New(http.StatusBadRequest, "code param not found")
}
if r.FormValue("state") == "" {
plog.Info("state param not found")
return nil, httperr.New(http.StatusBadRequest, "state param not found")
}
state, err := readState(r, stateDecoder)
if err != nil {
plog.InfoErr("error reading state", err)
return nil, err
}
if subtle.ConstantTimeCompare([]byte(state.CSRFToken), []byte(csrfValue)) != 1 {
plog.InfoErr("CSRF value does not match", err)
return nil, httperr.Wrap(http.StatusForbidden, "CSRF value does not match", err)
}
return state, nil
}
func findUpstreamIDPConfig(upstreamName string, idpListGetter oidc.IDPListGetter) provider.UpstreamOIDCIdentityProviderI {
for _, p := range idpListGetter.GetIDPList() {
if p.GetName() == upstreamName {
return p
}
}
return nil
}
func readCSRFCookie(r *http.Request, cookieDecoder oidc.Decoder) (csrftoken.CSRFToken, error) {
receivedCSRFCookie, err := r.Cookie(oidc.CSRFCookieName)
if err != nil {
// Error means that the cookie was not found
return "", httperr.Wrap(http.StatusForbidden, "CSRF cookie is missing", err)
}
var csrfFromCookie csrftoken.CSRFToken
err = cookieDecoder.Decode(oidc.CSRFCookieEncodingName, receivedCSRFCookie.Value, &csrfFromCookie)
if err != nil {
return "", httperr.Wrap(http.StatusForbidden, "error reading CSRF cookie", err)
}
return csrfFromCookie, nil
}
func readState(r *http.Request, stateDecoder oidc.Decoder) (*oidc.UpstreamStateParamData, error) {
var state oidc.UpstreamStateParamData
if err := stateDecoder.Decode(
oidc.UpstreamStateParamEncodingName,
r.FormValue("state"),
&state,
); err != nil {
return nil, httperr.New(http.StatusBadRequest, "error reading state")
}
if state.FormatVersion != oidc.UpstreamStateParamFormatVersion {
return nil, httperr.New(http.StatusUnprocessableEntity, "state format version is invalid")
}
return &state, nil
}
func grantOpenIDScopeIfRequested(authorizeRequester fosite.AuthorizeRequester) {
for _, scope := range authorizeRequester.GetRequestedScopes() {
if scope == "openid" {
authorizeRequester.GrantScope(scope)
}
}
}
func getUsernameFromUpstreamIDToken(
upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI,
idTokenClaims map[string]interface{},
) (string, error) {
usernameClaim := upstreamIDPConfig.GetUsernameClaim()
user := ""
if usernameClaim == "" {
// The spec says the "sub" claim is only unique per issuer, so by default when there is
// no specific username claim configured we will prepend the issuer string to make it globally unique.
upstreamIssuer := idTokenClaims[idTokenIssuerClaim]
if upstreamIssuer == "" {
plog.Warning(
"issuer claim in upstream ID token missing",
"upstreamName", upstreamIDPConfig.GetName(),
"issClaim", upstreamIssuer,
)
return "", httperr.New(http.StatusUnprocessableEntity, "issuer claim in upstream ID token missing")
}
upstreamIssuerAsString, ok := upstreamIssuer.(string)
if !ok {
plog.Warning(
"issuer claim in upstream ID token has invalid format",
"upstreamName", upstreamIDPConfig.GetName(),
"issClaim", upstreamIssuer,
)
return "", httperr.New(http.StatusUnprocessableEntity, "issuer claim in upstream ID token has invalid format")
}
user = fmt.Sprintf("%s?%s=", upstreamIssuerAsString, idTokenSubjectClaim)
usernameClaim = defaultUpstreamUsernameClaim
}
usernameAsInterface, ok := idTokenClaims[usernameClaim]
if !ok {
plog.Warning(
"no username claim in upstream ID token",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredUsernameClaim", upstreamIDPConfig.GetUsernameClaim(),
"usernameClaim", usernameClaim,
)
return "", httperr.New(http.StatusUnprocessableEntity, "no username claim in upstream ID token")
}
username, ok := usernameAsInterface.(string)
if !ok {
plog.Warning(
"username claim in upstream ID token has invalid format",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredUsernameClaim", upstreamIDPConfig.GetUsernameClaim(),
"usernameClaim", usernameClaim,
)
return "", httperr.New(http.StatusUnprocessableEntity, "username claim in upstream ID token has invalid format")
}
return fmt.Sprintf("%s%s", user, username), nil
}
func getGroupsFromUpstreamIDToken(
upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI,
idTokenClaims map[string]interface{},
) ([]string, error) {
groupsClaim := upstreamIDPConfig.GetGroupsClaim()
if groupsClaim == "" {
return nil, nil
}
groupsAsInterface, ok := idTokenClaims[groupsClaim]
if !ok {
plog.Warning(
"no groups claim in upstream ID token",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredGroupsClaim", upstreamIDPConfig.GetGroupsClaim(),
"groupsClaim", groupsClaim,
)
return nil, httperr.New(http.StatusUnprocessableEntity, "no groups claim in upstream ID token")
}
groups, ok := groupsAsInterface.([]string)
if !ok {
plog.Warning(
"groups claim in upstream ID token has invalid format",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredGroupsClaim", upstreamIDPConfig.GetGroupsClaim(),
"groupsClaim", groupsClaim,
)
return nil, httperr.New(http.StatusUnprocessableEntity, "groups claim in upstream ID token has invalid format")
}
return groups, nil
}
func makeDownstreamSession(username string, groups []string) *openid.DefaultSession {
now := time.Now().UTC()
openIDSession := &openid.DefaultSession{
Claims: &jwt.IDTokenClaims{
Subject: username,
RequestedAt: now,
AuthTime: now,
},
}
if groups != nil {
openIDSession.Claims.Extra = map[string]interface{}{
downstreamGroupsClaim: groups,
}
}
return openIDSession
}
@@ -0,0 +1,859 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package callback
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"time"
"github.com/gorilla/securecookie"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/kubernetes/fake"
kubetesting "k8s.io/client-go/testing"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/oidctestutil"
"go.pinniped.dev/internal/testutil"
"go.pinniped.dev/pkg/oidcclient/nonce"
"go.pinniped.dev/pkg/oidcclient/oidctypes"
"go.pinniped.dev/pkg/oidcclient/pkce"
)
const (
happyUpstreamIDPName = "upstream-idp-name"
upstreamIssuer = "https://my-upstream-issuer.com"
upstreamSubject = "abc123-some-guid"
upstreamUsername = "test-pinniped-username"
upstreamUsernameClaim = "the-user-claim"
upstreamGroupsClaim = "the-groups-claim"
happyUpstreamAuthcode = "upstream-auth-code"
happyUpstreamRedirectURI = "https://example.com/callback"
happyDownstreamState = "some-downstream-state-with-at-least-32-bytes"
happyDownstreamCSRF = "test-csrf"
happyDownstreamPKCE = "test-pkce"
happyDownstreamNonce = "test-nonce"
happyDownstreamStateVersion = "1"
downstreamIssuer = "https://my-downstream-issuer.com/path"
downstreamRedirectURI = "http://127.0.0.1/callback"
downstreamClientID = "pinniped-cli"
downstreamNonce = "some-nonce-value"
downstreamPKCEChallenge = "some-challenge"
downstreamPKCEChallengeMethod = "S256"
timeComparisonFudgeFactor = time.Second * 15
)
var (
upstreamGroupMembership = []string{"test-pinniped-group-0", "test-pinniped-group-1"}
happyDownstreamScopesRequested = []string{"openid", "profile", "email"}
happyDownstreamRequestParamsQuery = url.Values{
"response_type": []string{"code"},
"scope": []string{strings.Join(happyDownstreamScopesRequested, " ")},
"client_id": []string{downstreamClientID},
"state": []string{happyDownstreamState},
"nonce": []string{downstreamNonce},
"code_challenge": []string{downstreamPKCEChallenge},
"code_challenge_method": []string{downstreamPKCEChallengeMethod},
"redirect_uri": []string{downstreamRedirectURI},
}
happyDownstreamRequestParams = happyDownstreamRequestParamsQuery.Encode()
)
func TestCallbackEndpoint(t *testing.T) {
otherUpstreamOIDCIdentityProvider := oidctestutil.TestUpstreamOIDCIdentityProvider{
Name: "other-upstream-idp-name",
ClientID: "other-some-client-id",
Scopes: []string{"other-scope1", "other-scope2"},
}
var stateEncoderHashKey = []byte("fake-hash-secret")
var stateEncoderBlockKey = []byte("0123456789ABCDEF") // block encryption requires 16/24/32 bytes for AES
var cookieEncoderHashKey = []byte("fake-hash-secret2")
var cookieEncoderBlockKey = []byte("0123456789ABCDE2") // block encryption requires 16/24/32 bytes for AES
require.NotEqual(t, stateEncoderHashKey, cookieEncoderHashKey)
require.NotEqual(t, stateEncoderBlockKey, cookieEncoderBlockKey)
var happyStateCodec = securecookie.New(stateEncoderHashKey, stateEncoderBlockKey)
happyStateCodec.SetSerializer(securecookie.JSONEncoder{})
var happyCookieCodec = securecookie.New(cookieEncoderHashKey, cookieEncoderBlockKey)
happyCookieCodec.SetSerializer(securecookie.JSONEncoder{})
happyState := happyUpstreamStateParam().Build(t, happyStateCodec)
encodedIncomingCookieCSRFValue, err := happyCookieCodec.Encode("csrf", happyDownstreamCSRF)
require.NoError(t, err)
happyCSRFCookie := "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue
happyExchangeAndValidateTokensArgs := &oidctestutil.ExchangeAuthcodeAndValidateTokenArgs{
Authcode: happyUpstreamAuthcode,
PKCECodeVerifier: pkce.Code(happyDownstreamPKCE),
ExpectedIDTokenNonce: nonce.Nonce(happyDownstreamNonce),
RedirectURI: happyUpstreamRedirectURI,
}
// Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it
happyDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid&state=` + happyDownstreamState
tests := []struct {
name string
idp oidctestutil.TestUpstreamOIDCIdentityProvider
method string
path string
csrfCookie string
wantStatus int
wantBody string
wantRedirectLocationRegexp string
wantGrantedOpenidScope bool
wantDownstreamIDTokenSubject string
wantDownstreamIDTokenGroups []string
wantDownstreamRequestedScopes []string
wantDownstreamNonce string
wantDownstreamPKCEChallenge string
wantDownstreamPKCEChallengeMethod string
wantExchangeAndValidateTokensCall *oidctestutil.ExchangeAuthcodeAndValidateTokenArgs
}{
{
name: "GET with good state and cookie and successful upstream token exchange returns 302 to downstream client callback with its state and code",
idp: happyUpstream().Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantGrantedOpenidScope: true,
wantBody: "",
wantDownstreamIDTokenSubject: upstreamUsername,
wantDownstreamIDTokenGroups: upstreamGroupMembership,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
},
{
name: "upstream IDP provides no username or group claim configuration, so we use default username claim and skip groups",
idp: happyUpstream().WithoutUsernameClaim().WithoutGroupsClaim().Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantGrantedOpenidScope: true,
wantBody: "",
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + upstreamSubject,
wantDownstreamIDTokenGroups: nil,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
},
{
name: "upstream IDP provides username claim configuration as `sub`, so the downstream token subject should be exactly what they asked for",
idp: happyUpstream().WithUsernameClaim("sub").Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantGrantedOpenidScope: true,
wantBody: "",
wantDownstreamIDTokenSubject: upstreamSubject,
wantDownstreamIDTokenGroups: upstreamGroupMembership,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
},
// Pre-upstream-exchange verification
{
name: "PUT method is invalid",
method: http.MethodPut,
path: newRequestPath().String(),
wantStatus: http.StatusMethodNotAllowed,
wantBody: "Method Not Allowed: PUT (try GET)\n",
},
{
name: "POST method is invalid",
method: http.MethodPost,
path: newRequestPath().String(),
wantStatus: http.StatusMethodNotAllowed,
wantBody: "Method Not Allowed: POST (try GET)\n",
},
{
name: "PATCH method is invalid",
method: http.MethodPatch,
path: newRequestPath().String(),
wantStatus: http.StatusMethodNotAllowed,
wantBody: "Method Not Allowed: PATCH (try GET)\n",
},
{
name: "DELETE method is invalid",
method: http.MethodDelete,
path: newRequestPath().String(),
wantStatus: http.StatusMethodNotAllowed,
wantBody: "Method Not Allowed: DELETE (try GET)\n",
},
{
name: "code param was not included on request",
method: http.MethodGet,
path: newRequestPath().WithState(happyState).WithoutCode().String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusBadRequest,
wantBody: "Bad Request: code param not found\n",
},
{
name: "state param was not included on request",
method: http.MethodGet,
path: newRequestPath().WithoutState().String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusBadRequest,
wantBody: "Bad Request: state param not found\n",
},
{
name: "state param was not signed correctly, has expired, or otherwise cannot be decoded for any reason",
idp: happyUpstream().Build(),
method: http.MethodGet,
path: newRequestPath().WithState("this-will-not-decode").String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusBadRequest,
wantBody: "Bad Request: error reading state\n",
},
{
// This shouldn't happen in practice because the authorize endpoint should have already run the same
// validations, but we would like to test the error handling in this endpoint anyway.
name: "state param contains authorization request params which fail validation",
idp: happyUpstream().Build(),
method: http.MethodGet,
path: newRequestPath().WithState(
happyUpstreamStateParam().
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"prompt": "none login"}).Encode()).
Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
wantStatus: http.StatusInternalServerError,
wantBody: "Internal Server Error: error while generating and saving authcode\n",
},
{
name: "state's internal version does not match what we want",
idp: happyUpstream().Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyUpstreamStateParam().WithStateVersion("wrong-state-version").Build(t, happyStateCodec)).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusUnprocessableEntity,
wantBody: "Unprocessable Entity: state format version is invalid\n",
},
{
name: "state's downstream auth params element is invalid",
idp: happyUpstream().Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyUpstreamStateParam().
WithAuthorizeRequestParams("the following is an invalid url encoding token, and therefore this is an invalid param: %z").
Build(t, happyStateCodec)).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusBadRequest,
wantBody: "Bad Request: error reading state downstream auth params\n",
},
{
name: "state's downstream auth params are missing required value (e.g., client_id)",
idp: happyUpstream().Build(),
method: http.MethodGet,
path: newRequestPath().WithState(
happyUpstreamStateParam().
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"client_id": ""}).Encode()).
Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusBadRequest,
wantBody: "Bad Request: error using state downstream auth params\n",
},
{
name: "state's downstream auth params does not contain openid scope",
idp: happyUpstream().Build(),
method: http.MethodGet,
path: newRequestPath().
WithState(
happyUpstreamStateParam().
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"scope": "profile email"}).Encode()).
Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyDownstreamState,
wantDownstreamIDTokenSubject: upstreamUsername,
wantDownstreamRequestedScopes: []string{"profile", "email"},
wantDownstreamIDTokenGroups: upstreamGroupMembership,
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
},
{
name: "the UpstreamOIDCProvider CRD has been deleted",
idp: otherUpstreamOIDCIdentityProvider,
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusUnprocessableEntity,
wantBody: "Unprocessable Entity: upstream provider not found\n",
},
{
name: "the CSRF cookie does not exist on request",
idp: happyUpstream().Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
wantStatus: http.StatusForbidden,
wantBody: "Forbidden: CSRF cookie is missing\n",
},
{
name: "cookie was not signed correctly, has expired, or otherwise cannot be decoded for any reason",
idp: happyUpstream().Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped",
wantStatus: http.StatusForbidden,
wantBody: "Forbidden: error reading CSRF cookie\n",
},
{
name: "cookie csrf value does not match state csrf value",
idp: happyUpstream().Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyUpstreamStateParam().WithCSRF("wrong-csrf-value").Build(t, happyStateCodec)).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusForbidden,
wantBody: "Forbidden: CSRF value does not match\n",
},
// Upstream exchange
{
name: "upstream auth code exchange fails",
idp: happyUpstream().WithoutUpstreamAuthcodeExchangeError(errors.New("some error")).Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusBadGateway,
wantBody: "Bad Gateway: error exchanging and validating upstream tokens\n",
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
},
{
name: "upstream ID token does not contain requested username claim",
idp: happyUpstream().WithoutIDTokenClaim(upstreamUsernameClaim).Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusUnprocessableEntity,
wantBody: "Unprocessable Entity: no username claim in upstream ID token\n",
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
},
{
name: "upstream ID token does not contain requested groups claim",
idp: happyUpstream().WithoutIDTokenClaim(upstreamGroupsClaim).Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusUnprocessableEntity,
wantBody: "Unprocessable Entity: no groups claim in upstream ID token\n",
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
},
{
name: "upstream ID token contains username claim with weird format",
idp: happyUpstream().WithIDTokenClaim(upstreamUsernameClaim, 42).Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusUnprocessableEntity,
wantBody: "Unprocessable Entity: username claim in upstream ID token has invalid format\n",
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
},
{
name: "upstream ID token does not contain iss claim when using default username claim config",
idp: happyUpstream().WithIDTokenClaim("iss", "").WithoutUsernameClaim().Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusUnprocessableEntity,
wantBody: "Unprocessable Entity: issuer claim in upstream ID token missing\n",
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
},
{
name: "upstream ID token has an non-string iss claim when using default username claim config",
idp: happyUpstream().WithIDTokenClaim("iss", 42).WithoutUsernameClaim().Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusUnprocessableEntity,
wantBody: "Unprocessable Entity: issuer claim in upstream ID token has invalid format\n",
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
},
{
name: "upstream ID token contains groups claim with weird format",
idp: happyUpstream().WithIDTokenClaim(upstreamGroupsClaim, 42).Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusUnprocessableEntity,
wantBody: "Unprocessable Entity: groups claim in upstream ID token has invalid format\n",
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets("some-namespace")
// Configure fosite the same way that the production code would.
// Inject this into our test subject at the last second so we get a fresh storage for every test.
oauthStore := oidc.NewKubeStorage(secrets)
hmacSecret := []byte("some secret - must have at least 32 bytes")
require.GreaterOrEqual(t, len(hmacSecret), 32, "fosite requires that hmac secrets have at least 32 bytes")
oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecret)
idpListGetter := oidctestutil.NewIDPListGetter(&test.idp)
subject := NewHandler(idpListGetter, oauthHelper, happyStateCodec, happyCookieCodec, happyUpstreamRedirectURI)
req := httptest.NewRequest(test.method, test.path, nil)
if test.csrfCookie != "" {
req.Header.Set("Cookie", test.csrfCookie)
}
rsp := httptest.NewRecorder()
subject.ServeHTTP(rsp, req)
t.Logf("response: %#v", rsp)
t.Logf("response body: %q", rsp.Body.String())
if test.wantExchangeAndValidateTokensCall != nil {
require.Equal(t, 1, test.idp.ExchangeAuthcodeAndValidateTokensCallCount())
test.wantExchangeAndValidateTokensCall.Ctx = req.Context()
require.Equal(t, test.wantExchangeAndValidateTokensCall, test.idp.ExchangeAuthcodeAndValidateTokensArgs(0))
} else {
require.Equal(t, 0, test.idp.ExchangeAuthcodeAndValidateTokensCallCount())
}
require.Equal(t, test.wantStatus, rsp.Code)
if test.wantBody != "" {
require.Equal(t, test.wantBody, rsp.Body.String())
} else {
require.Empty(t, rsp.Body.String())
}
if test.wantRedirectLocationRegexp != "" { //nolint:nestif // don't mind have several sequential if statements in this test
// Assert that Location header matches regular expression.
require.Len(t, rsp.Header().Values("Location"), 1)
actualLocation := rsp.Header().Get("Location")
regex := regexp.MustCompile(test.wantRedirectLocationRegexp)
submatches := regex.FindStringSubmatch(actualLocation)
require.Lenf(t, submatches, 2, "no regexp match in actualLocation: %q", actualLocation)
capturedAuthCode := submatches[1]
// fosite authcodes are in the format `data.signature`, so grab the signature part, which is the lookup key in the storage interface
authcodeDataAndSignature := strings.Split(capturedAuthCode, ".")
require.Len(t, authcodeDataAndSignature, 2)
// Several Secrets should have been created
expectedNumberOfCreatedSecrets := 2
if test.wantGrantedOpenidScope {
expectedNumberOfCreatedSecrets++
}
require.Len(t, client.Actions(), expectedNumberOfCreatedSecrets)
actualSecretNames := []string{}
for i := range client.Actions() {
actualAction := client.Actions()[i].(kubetesting.CreateActionImpl)
require.Equal(t, "create", actualAction.GetVerb())
require.Equal(t, schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"}, actualAction.GetResource())
actualSecret := actualAction.GetObject().(*corev1.Secret)
require.Empty(t, actualSecret.Namespace) // because the secrets client is already scoped to a namespace
actualSecretNames = append(actualSecretNames, actualSecret.Name)
}
// One authcode should have been stored.
requireAnyStringHasPrefix(t, actualSecretNames, "pinniped-storage-authcode-")
storedRequestFromAuthcode, storedSessionFromAuthcode := validateAuthcodeStorage(
t,
oauthStore,
authcodeDataAndSignature[1], // Authcode store key is authcode signature
test.wantGrantedOpenidScope,
test.wantDownstreamIDTokenSubject,
test.wantDownstreamIDTokenGroups,
test.wantDownstreamRequestedScopes,
)
// One PKCE should have been stored.
requireAnyStringHasPrefix(t, actualSecretNames, "pinniped-storage-pkce-")
validatePKCEStorage(
t,
oauthStore,
authcodeDataAndSignature[1], // PKCE store key is authcode signature
storedRequestFromAuthcode,
storedSessionFromAuthcode,
test.wantDownstreamPKCEChallenge,
test.wantDownstreamPKCEChallengeMethod,
)
// One IDSession should have been stored, if the downstream actually requested the "openid" scope
if test.wantGrantedOpenidScope {
requireAnyStringHasPrefix(t, actualSecretNames, "pinniped-storage-oidc")
validateIDSessionStorage(
t,
oauthStore,
capturedAuthCode, // IDSession store key is full authcode
storedRequestFromAuthcode,
storedSessionFromAuthcode,
test.wantDownstreamNonce,
)
}
}
})
}
}
type requestPath struct {
code, state *string
}
func newRequestPath() *requestPath {
c := happyUpstreamAuthcode
s := "4321"
return &requestPath{
code: &c,
state: &s,
}
}
func (r *requestPath) WithCode(code string) *requestPath {
r.code = &code
return r
}
func (r *requestPath) WithoutCode() *requestPath {
r.code = nil
return r
}
func (r *requestPath) WithState(state string) *requestPath {
r.state = &state
return r
}
func (r *requestPath) WithoutState() *requestPath {
r.state = nil
return r
}
func (r *requestPath) String() string {
path := "/downstream-provider-name/callback?"
params := url.Values{}
if r.code != nil {
params.Add("code", *r.code)
}
if r.state != nil {
params.Add("state", *r.state)
}
return path + params.Encode()
}
type upstreamStateParamBuilder oidctestutil.ExpectedUpstreamStateParamFormat
func happyUpstreamStateParam() *upstreamStateParamBuilder {
return &upstreamStateParamBuilder{
U: happyUpstreamIDPName,
P: happyDownstreamRequestParams,
N: happyDownstreamNonce,
C: happyDownstreamCSRF,
K: happyDownstreamPKCE,
V: happyDownstreamStateVersion,
}
}
func (b upstreamStateParamBuilder) Build(t *testing.T, stateEncoder *securecookie.SecureCookie) string {
state, err := stateEncoder.Encode("s", b)
require.NoError(t, err)
return state
}
func (b *upstreamStateParamBuilder) WithAuthorizeRequestParams(params string) *upstreamStateParamBuilder {
b.P = params
return b
}
func (b *upstreamStateParamBuilder) WithNonce(nonce string) *upstreamStateParamBuilder {
b.N = nonce
return b
}
func (b *upstreamStateParamBuilder) WithCSRF(csrf string) *upstreamStateParamBuilder {
b.C = csrf
return b
}
func (b *upstreamStateParamBuilder) WithPKCVE(pkce string) *upstreamStateParamBuilder {
b.K = pkce
return b
}
func (b *upstreamStateParamBuilder) WithStateVersion(version string) *upstreamStateParamBuilder {
b.V = version
return b
}
type upstreamOIDCIdentityProviderBuilder struct {
idToken map[string]interface{}
usernameClaim, groupsClaim string
authcodeExchangeErr error
}
func happyUpstream() *upstreamOIDCIdentityProviderBuilder {
return &upstreamOIDCIdentityProviderBuilder{
usernameClaim: upstreamUsernameClaim,
groupsClaim: upstreamGroupsClaim,
idToken: map[string]interface{}{
"iss": upstreamIssuer,
"sub": upstreamSubject,
upstreamUsernameClaim: upstreamUsername,
upstreamGroupsClaim: upstreamGroupMembership,
"other-claim": "should be ignored",
},
}
}
func (u *upstreamOIDCIdentityProviderBuilder) WithUsernameClaim(claim string) *upstreamOIDCIdentityProviderBuilder {
u.usernameClaim = claim
return u
}
func (u *upstreamOIDCIdentityProviderBuilder) WithoutUsernameClaim() *upstreamOIDCIdentityProviderBuilder {
u.usernameClaim = ""
return u
}
func (u *upstreamOIDCIdentityProviderBuilder) WithoutGroupsClaim() *upstreamOIDCIdentityProviderBuilder {
u.groupsClaim = ""
return u
}
func (u *upstreamOIDCIdentityProviderBuilder) WithIDTokenClaim(name string, value interface{}) *upstreamOIDCIdentityProviderBuilder {
u.idToken[name] = value
return u
}
func (u *upstreamOIDCIdentityProviderBuilder) WithoutIDTokenClaim(claim string) *upstreamOIDCIdentityProviderBuilder {
delete(u.idToken, claim)
return u
}
func (u *upstreamOIDCIdentityProviderBuilder) WithoutUpstreamAuthcodeExchangeError(err error) *upstreamOIDCIdentityProviderBuilder {
u.authcodeExchangeErr = err
return u
}
func (u *upstreamOIDCIdentityProviderBuilder) Build() oidctestutil.TestUpstreamOIDCIdentityProvider {
return oidctestutil.TestUpstreamOIDCIdentityProvider{
Name: happyUpstreamIDPName,
ClientID: "some-client-id",
UsernameClaim: u.usernameClaim,
GroupsClaim: u.groupsClaim,
Scopes: []string{"scope1", "scope2"},
ExchangeAuthcodeAndValidateTokensFunc: func(ctx context.Context, authcode string, pkceCodeVerifier pkce.Code, expectedIDTokenNonce nonce.Nonce) (oidctypes.Token, map[string]interface{}, error) {
return oidctypes.Token{}, u.idToken, u.authcodeExchangeErr
},
}
}
func shallowCopyAndModifyQuery(query url.Values, modifications map[string]string) url.Values {
copied := url.Values{}
for key, value := range query {
copied[key] = value
}
for key, value := range modifications {
if value == "" {
copied.Del(key)
} else {
copied[key] = []string{value}
}
}
return copied
}
func validateAuthcodeStorage(
t *testing.T,
oauthStore *oidc.KubeStorage,
storeKey string,
wantGrantedOpenidScope bool,
wantDownstreamIDTokenSubject string,
wantDownstreamIDTokenGroups []string,
wantDownstreamRequestedScopes []string,
) (*fosite.Request, *openid.DefaultSession) {
t.Helper()
// Get the authcode session back from storage so we can require that it was stored correctly.
storedAuthorizeRequestFromAuthcode, err := oauthStore.GetAuthorizeCodeSession(context.Background(), storeKey, nil)
require.NoError(t, err)
// Check that storage returned the expected concrete data types.
storedRequestFromAuthcode, storedSessionFromAuthcode := castStoredAuthorizeRequest(t, storedAuthorizeRequestFromAuthcode)
// Check which scopes were granted.
if wantGrantedOpenidScope {
require.Contains(t, storedRequestFromAuthcode.GetGrantedScopes(), "openid")
} else {
require.NotContains(t, storedRequestFromAuthcode.GetGrantedScopes(), "openid")
}
// Check all the other fields of the stored request.
require.NotEmpty(t, storedRequestFromAuthcode.ID)
require.Equal(t, downstreamClientID, storedRequestFromAuthcode.Client.GetID())
require.ElementsMatch(t, wantDownstreamRequestedScopes, storedRequestFromAuthcode.RequestedScope)
require.Nil(t, storedRequestFromAuthcode.RequestedAudience)
require.Empty(t, storedRequestFromAuthcode.GrantedAudience)
require.Equal(t, url.Values{"redirect_uri": []string{downstreamRedirectURI}}, storedRequestFromAuthcode.Form)
testutil.RequireTimeInDelta(t, time.Now(), storedRequestFromAuthcode.RequestedAt, timeComparisonFudgeFactor)
// We're not using these fields yet, so confirm that we did not set them (for now).
require.Empty(t, storedSessionFromAuthcode.Subject)
require.Empty(t, storedSessionFromAuthcode.Username)
require.Empty(t, storedSessionFromAuthcode.Headers)
// The authcode that we are issuing should be good for the length of time that we declare in the fosite config.
testutil.RequireTimeInDelta(t, time.Now().Add(time.Minute*3), storedSessionFromAuthcode.ExpiresAt[fosite.AuthorizeCode], timeComparisonFudgeFactor)
require.Len(t, storedSessionFromAuthcode.ExpiresAt, 1)
// Now confirm the ID token claims.
actualClaims := storedSessionFromAuthcode.Claims
// Check the user's identity, which are put into the downstream ID token's subject and groups claims.
require.Equal(t, wantDownstreamIDTokenSubject, actualClaims.Subject)
if wantDownstreamIDTokenGroups != nil {
require.Len(t, actualClaims.Extra, 1)
require.ElementsMatch(t, wantDownstreamIDTokenGroups, actualClaims.Extra["groups"])
} else {
require.Empty(t, actualClaims.Extra)
require.NotContains(t, actualClaims.Extra, "groups")
}
// Check the rest of the downstream ID token's claims. Fosite wants us to set these (in UTC time).
testutil.RequireTimeInDelta(t, time.Now().UTC(), actualClaims.RequestedAt, timeComparisonFudgeFactor)
testutil.RequireTimeInDelta(t, time.Now().UTC(), actualClaims.AuthTime, timeComparisonFudgeFactor)
requestedAtZone, _ := actualClaims.RequestedAt.Zone()
require.Equal(t, "UTC", requestedAtZone)
authTimeZone, _ := actualClaims.AuthTime.Zone()
require.Equal(t, "UTC", authTimeZone)
// Fosite will set these fields for us in the token endpoint based on the store session
// information. Therefore, we assert that they are empty because we want the library to do the
// lifting for us.
require.Empty(t, actualClaims.Issuer)
require.Nil(t, actualClaims.Audience)
require.Empty(t, actualClaims.Nonce)
require.Zero(t, actualClaims.ExpiresAt)
require.Zero(t, actualClaims.IssuedAt)
// These are not needed yet.
require.Empty(t, actualClaims.JTI)
require.Empty(t, actualClaims.CodeHash)
require.Empty(t, actualClaims.AccessTokenHash)
require.Empty(t, actualClaims.AuthenticationContextClassReference)
require.Empty(t, actualClaims.AuthenticationMethodsReference)
return storedRequestFromAuthcode, storedSessionFromAuthcode
}
func validatePKCEStorage(
t *testing.T,
oauthStore *oidc.KubeStorage,
storeKey string,
storedRequestFromAuthcode *fosite.Request,
storedSessionFromAuthcode *openid.DefaultSession,
wantDownstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod string,
) {
t.Helper()
storedAuthorizeRequestFromPKCE, err := oauthStore.GetPKCERequestSession(context.Background(), storeKey, nil)
require.NoError(t, err)
// Check that storage returned the expected concrete data types.
storedRequestFromPKCE, storedSessionFromPKCE := castStoredAuthorizeRequest(t, storedAuthorizeRequestFromPKCE)
// The stored PKCE request should be the same as the stored authcode request.
require.Equal(t, storedRequestFromAuthcode.ID, storedRequestFromPKCE.ID)
require.Equal(t, storedSessionFromAuthcode, storedSessionFromPKCE)
// The stored PKCE request should also contain the PKCE challenge that the downstream sent us.
require.Equal(t, wantDownstreamPKCEChallenge, storedRequestFromPKCE.Form.Get("code_challenge"))
require.Equal(t, wantDownstreamPKCEChallengeMethod, storedRequestFromPKCE.Form.Get("code_challenge_method"))
}
func validateIDSessionStorage(
t *testing.T,
oauthStore *oidc.KubeStorage,
storeKey string,
storedRequestFromAuthcode *fosite.Request,
storedSessionFromAuthcode *openid.DefaultSession,
wantDownstreamNonce string,
) {
t.Helper()
storedAuthorizeRequestFromIDSession, err := oauthStore.GetOpenIDConnectSession(context.Background(), storeKey, nil)
require.NoError(t, err)
// Check that storage returned the expected concrete data types.
storedRequestFromIDSession, storedSessionFromIDSession := castStoredAuthorizeRequest(t, storedAuthorizeRequestFromIDSession)
// The stored IDSession request should be the same as the stored authcode request.
require.Equal(t, storedRequestFromAuthcode.ID, storedRequestFromIDSession.ID)
require.Equal(t, storedSessionFromAuthcode, storedSessionFromIDSession)
// The stored IDSession request should also contain the nonce that the downstream sent us.
require.Equal(t, wantDownstreamNonce, storedRequestFromIDSession.Form.Get("nonce"))
}
func castStoredAuthorizeRequest(t *testing.T, storedAuthorizeRequest fosite.Requester) (*fosite.Request, *openid.DefaultSession) {
t.Helper()
storedRequest, ok := storedAuthorizeRequest.(*fosite.Request)
require.Truef(t, ok, "could not cast %T to %T", storedAuthorizeRequest, &fosite.Request{})
storedSession, ok := storedAuthorizeRequest.GetSession().(*openid.DefaultSession)
require.Truef(t, ok, "could not cast %T to %T", storedAuthorizeRequest.GetSession(), &openid.DefaultSession{})
return storedRequest, storedSession
}
func requireAnyStringHasPrefix(t *testing.T, stringList []string, prefix string) {
t.Helper()
containsPrefix := false
for i := range stringList {
if strings.HasPrefix(stringList[i], prefix) {
containsPrefix = true
}
}
require.Truef(t, containsPrefix, "list %v did not contain any strings with prefix %s", stringList, prefix)
}
+120
View File
@@ -0,0 +1,120 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package oidc
import (
"context"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/oauth2"
"github.com/ory/fosite/handler/openid"
fositepkce "github.com/ory/fosite/handler/pkce"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
"go.pinniped.dev/internal/constable"
"go.pinniped.dev/internal/fositestorage/authorizationcode"
"go.pinniped.dev/internal/fositestorage/openidconnect"
"go.pinniped.dev/internal/fositestorage/pkce"
)
const errKubeStorageNotImplemented = constable.Error("KubeStorage does not implement this method. It should not have been called.")
type KubeStorage struct {
authorizationCodeStorage oauth2.AuthorizeCodeStorage
pkceStorage fositepkce.PKCERequestStorage
oidcStorage openid.OpenIDConnectRequestStorage
}
func NewKubeStorage(secrets corev1client.SecretInterface) *KubeStorage {
return &KubeStorage{
authorizationCodeStorage: authorizationcode.New(secrets),
pkceStorage: pkce.New(secrets),
oidcStorage: openidconnect.New(secrets),
}
}
func (KubeStorage) RevokeRefreshToken(_ context.Context, _ string) error {
return errKubeStorageNotImplemented
}
func (KubeStorage) RevokeAccessToken(_ context.Context, _ string) error {
return errKubeStorageNotImplemented
}
func (KubeStorage) CreateRefreshTokenSession(_ context.Context, _ string, _ fosite.Requester) (err error) {
return nil
}
func (KubeStorage) GetRefreshTokenSession(_ context.Context, _ string, _ fosite.Session) (request fosite.Requester, err error) {
return nil, errKubeStorageNotImplemented
}
func (KubeStorage) DeleteRefreshTokenSession(_ context.Context, _ string) (err error) {
return errKubeStorageNotImplemented
}
func (KubeStorage) CreateAccessTokenSession(_ context.Context, _ string, _ fosite.Requester) (err error) {
return nil
}
func (KubeStorage) GetAccessTokenSession(_ context.Context, _ string, _ fosite.Session) (request fosite.Requester, err error) {
return nil, errKubeStorageNotImplemented
}
func (KubeStorage) DeleteAccessTokenSession(_ context.Context, _ string) (err error) {
return errKubeStorageNotImplemented
}
func (k KubeStorage) CreateOpenIDConnectSession(ctx context.Context, authcode string, requester fosite.Requester) error {
return k.oidcStorage.CreateOpenIDConnectSession(ctx, authcode, requester)
}
func (k KubeStorage) GetOpenIDConnectSession(ctx context.Context, authcode string, requester fosite.Requester) (fosite.Requester, error) {
return k.oidcStorage.GetOpenIDConnectSession(ctx, authcode, requester)
}
func (k KubeStorage) DeleteOpenIDConnectSession(ctx context.Context, authcode string) error {
return k.oidcStorage.DeleteOpenIDConnectSession(ctx, authcode)
}
func (k KubeStorage) GetPKCERequestSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) {
return k.pkceStorage.GetPKCERequestSession(ctx, signature, session)
}
func (k KubeStorage) CreatePKCERequestSession(ctx context.Context, signature string, requester fosite.Requester) error {
return k.pkceStorage.CreatePKCERequestSession(ctx, signature, requester)
}
func (k KubeStorage) DeletePKCERequestSession(ctx context.Context, signature string) error {
return k.pkceStorage.DeletePKCERequestSession(ctx, signature)
}
func (k KubeStorage) CreateAuthorizeCodeSession(ctx context.Context, signature string, r fosite.Requester) (err error) {
return k.authorizationCodeStorage.CreateAuthorizeCodeSession(ctx, signature, r)
}
func (k KubeStorage) GetAuthorizeCodeSession(ctx context.Context, signature string, s fosite.Session) (request fosite.Requester, err error) {
return k.authorizationCodeStorage.GetAuthorizeCodeSession(ctx, signature, s)
}
func (k KubeStorage) InvalidateAuthorizeCodeSession(ctx context.Context, signature string) (err error) {
return k.authorizationCodeStorage.InvalidateAuthorizeCodeSession(ctx, signature)
}
func (KubeStorage) GetClient(_ context.Context, id string) (fosite.Client, error) {
client := PinnipedCLIOIDCClient()
if client.ID == id {
return client, nil
}
return nil, fosite.ErrNotFound
}
func (KubeStorage) ClientAssertionJWTValid(_ context.Context, _ string) error {
return errKubeStorageNotImplemented
}
func (KubeStorage) SetClientAssertionJWT(_ context.Context, _ string, _ time.Time) error {
return errKubeStorageNotImplemented
}
+15 -15
View File
@@ -12,16 +12,16 @@ import (
"go.pinniped.dev/internal/constable"
)
const errNotImplemented = constable.Error("NullStorage does not implement this method. It should not have been called.")
const errNullStorageNotImplemented = constable.Error("NullStorage does not implement this method. It should not have been called.")
type NullStorage struct{}
func (NullStorage) RevokeRefreshToken(_ context.Context, _ string) error {
return errNotImplemented
return errNullStorageNotImplemented
}
func (NullStorage) RevokeAccessToken(_ context.Context, _ string) error {
return errNotImplemented
return errNullStorageNotImplemented
}
func (NullStorage) CreateRefreshTokenSession(_ context.Context, _ string, _ fosite.Requester) (err error) {
@@ -29,11 +29,11 @@ func (NullStorage) CreateRefreshTokenSession(_ context.Context, _ string, _ fosi
}
func (NullStorage) GetRefreshTokenSession(_ context.Context, _ string, _ fosite.Session) (request fosite.Requester, err error) {
return nil, errNotImplemented
return nil, errNullStorageNotImplemented
}
func (NullStorage) DeleteRefreshTokenSession(_ context.Context, _ string) (err error) {
return errNotImplemented
return errNullStorageNotImplemented
}
func (NullStorage) CreateAccessTokenSession(_ context.Context, _ string, _ fosite.Requester) (err error) {
@@ -41,11 +41,11 @@ func (NullStorage) CreateAccessTokenSession(_ context.Context, _ string, _ fosit
}
func (NullStorage) GetAccessTokenSession(_ context.Context, _ string, _ fosite.Session) (request fosite.Requester, err error) {
return nil, errNotImplemented
return nil, errNullStorageNotImplemented
}
func (NullStorage) DeleteAccessTokenSession(_ context.Context, _ string) (err error) {
return errNotImplemented
return errNullStorageNotImplemented
}
func (NullStorage) CreateOpenIDConnectSession(_ context.Context, _ string, _ fosite.Requester) error {
@@ -53,15 +53,15 @@ func (NullStorage) CreateOpenIDConnectSession(_ context.Context, _ string, _ fos
}
func (NullStorage) GetOpenIDConnectSession(_ context.Context, _ string, _ fosite.Requester) (fosite.Requester, error) {
return nil, errNotImplemented
return nil, errNullStorageNotImplemented
}
func (NullStorage) DeleteOpenIDConnectSession(_ context.Context, _ string) error {
return errNotImplemented
return errNullStorageNotImplemented
}
func (NullStorage) GetPKCERequestSession(_ context.Context, _ string, _ fosite.Session) (fosite.Requester, error) {
return nil, errNotImplemented
return nil, errNullStorageNotImplemented
}
func (NullStorage) CreatePKCERequestSession(_ context.Context, _ string, _ fosite.Requester) error {
@@ -69,7 +69,7 @@ func (NullStorage) CreatePKCERequestSession(_ context.Context, _ string, _ fosit
}
func (NullStorage) DeletePKCERequestSession(_ context.Context, _ string) error {
return errNotImplemented
return errNullStorageNotImplemented
}
func (NullStorage) CreateAuthorizeCodeSession(_ context.Context, _ string, _ fosite.Requester) (err error) {
@@ -77,11 +77,11 @@ func (NullStorage) CreateAuthorizeCodeSession(_ context.Context, _ string, _ fos
}
func (NullStorage) GetAuthorizeCodeSession(_ context.Context, _ string, _ fosite.Session) (request fosite.Requester, err error) {
return nil, errNotImplemented
return nil, errNullStorageNotImplemented
}
func (NullStorage) InvalidateAuthorizeCodeSession(_ context.Context, _ string) (err error) {
return errNotImplemented
return errNullStorageNotImplemented
}
func (NullStorage) GetClient(_ context.Context, id string) (fosite.Client, error) {
@@ -93,9 +93,9 @@ func (NullStorage) GetClient(_ context.Context, id string) (fosite.Client, error
}
func (NullStorage) ClientAssertionJWTValid(_ context.Context, _ string) error {
return errNotImplemented
return errNullStorageNotImplemented
}
func (NullStorage) SetClientAssertionJWT(_ context.Context, _ string, _ time.Time) error {
return errNotImplemented
return errNullStorageNotImplemented
}
+64 -3
View File
@@ -10,15 +10,72 @@ import (
"github.com/ory/fosite"
"github.com/ory/fosite/compose"
"go.pinniped.dev/internal/oidc/csrftoken"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/pkg/oidcclient/nonce"
"go.pinniped.dev/pkg/oidcclient/pkce"
)
const (
WellKnownEndpointPath = "/.well-known/openid-configuration"
AuthorizationEndpointPath = "/oauth2/authorize"
TokenEndpointPath = "/oauth2/token" //nolint:gosec // ignore lint warning that this is a credential
CallbackEndpointPath = "/callback"
JWKSEndpointPath = "/jwks.json"
)
const (
// Just in case we need to make a breaking change to the format of the upstream state param,
// we are including a format version number. This gives the opportunity for a future version of Pinniped
// to have the consumer of this format decide to reject versions that it doesn't understand.
UpstreamStateParamFormatVersion = "1"
// The `name` passed to the encoder for encoding the upstream state param value. This name is short
// because it will be encoded into the upstream state param value and we're trying to keep that small.
UpstreamStateParamEncodingName = "s"
// CSRFCookieName is the name of the browser cookie which shall hold our CSRF value.
// The `__Host` prefix has a special meaning. See:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Cookie_prefixes.
CSRFCookieName = "__Host-pinniped-csrf"
// CSRFCookieEncodingName is the `name` passed to the encoder for encoding and decoding the CSRF
// cookie contents.
CSRFCookieEncodingName = "csrf"
)
// Encoder is the encoding side of the securecookie.Codec interface.
type Encoder interface {
Encode(name string, value interface{}) (string, error)
}
// Decoder is the decoding side of the securecookie.Codec interface.
type Decoder interface {
Decode(name, value string, into interface{}) error
}
// Codec is both the encoding and decoding sides of the securecookie.Codec interface. It is
// interface'd here so that we properly wrap the securecookie dependency.
type Codec interface {
Encoder
Decoder
}
// UpstreamStateParamData is the format of the state parameter that we use when we communicate to an
// upstream OIDC provider.
//
// Keep the JSON to a minimal size because the upstream provider could impose size limitations on
// the state param.
type UpstreamStateParamData struct {
AuthParams string `json:"p"`
UpstreamName string `json:"u"`
Nonce nonce.Nonce `json:"n"`
CSRFToken csrftoken.CSRFToken `json:"c"`
PKCECode pkce.Code `json:"k"`
FormatVersion string `json:"v"`
}
func PinnipedCLIOIDCClient() *fosite.DefaultOpenIDConnectClient {
return &fosite.DefaultOpenIDConnectClient{
DefaultClient: &fosite.DefaultClient{
@@ -34,8 +91,8 @@ func PinnipedCLIOIDCClient() *fosite.DefaultOpenIDConnectClient {
}
func FositeOauth2Helper(
issuerURL string,
oauthStore fosite.Storage,
oauthStore interface{},
issuer string,
hmacSecretOfLengthAtLeast32 []byte,
jwtSigningKey *ecdsa.PrivateKey,
) fosite.OAuth2Provider {
@@ -47,7 +104,7 @@ func FositeOauth2Helper(
RefreshTokenLifespan: 16 * time.Hour, // long enough for a single workday
IDTokenIssuer: issuerURL,
IDTokenIssuer: issuer,
TokenURL: "", // TODO set once we have this endpoint written
ScopeStrategy: fosite.ExactScopeStrategy, // be careful and only support exact string matching for scopes
@@ -75,3 +132,7 @@ func FositeOauth2Helper(
compose.OAuth2PKCEFactory,
)
}
type IDPListGetter interface {
GetIDPList() []provider.UpstreamOIDCIdentityProviderI
}
+129
View File
@@ -0,0 +1,129 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package oidctestutil
import (
"context"
"net/url"
"golang.org/x/oauth2"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/pkg/oidcclient/nonce"
"go.pinniped.dev/pkg/oidcclient/oidctypes"
"go.pinniped.dev/pkg/oidcclient/pkce"
)
// Test helpers for the OIDC package.
// ExchangeAuthcodeAndValidateTokenArgs is a POGO (plain old go object?) used to spy on calls to
// TestUpstreamOIDCIdentityProvider.ExchangeAuthcodeAndValidateTokensFunc().
type ExchangeAuthcodeAndValidateTokenArgs struct {
Ctx context.Context
Authcode string
PKCECodeVerifier pkce.Code
ExpectedIDTokenNonce nonce.Nonce
RedirectURI string
}
type TestUpstreamOIDCIdentityProvider struct {
Name string
ClientID string
AuthorizationURL url.URL
UsernameClaim string
GroupsClaim string
Scopes []string
ExchangeAuthcodeAndValidateTokensFunc func(
ctx context.Context,
authcode string,
pkceCodeVerifier pkce.Code,
expectedIDTokenNonce nonce.Nonce,
) (oidctypes.Token, map[string]interface{}, error)
exchangeAuthcodeAndValidateTokensCallCount int
exchangeAuthcodeAndValidateTokensArgs []*ExchangeAuthcodeAndValidateTokenArgs
}
func (u *TestUpstreamOIDCIdentityProvider) GetName() string {
return u.Name
}
func (u *TestUpstreamOIDCIdentityProvider) GetClientID() string {
return u.ClientID
}
func (u *TestUpstreamOIDCIdentityProvider) GetAuthorizationURL() *url.URL {
return &u.AuthorizationURL
}
func (u *TestUpstreamOIDCIdentityProvider) GetScopes() []string {
return u.Scopes
}
func (u *TestUpstreamOIDCIdentityProvider) GetUsernameClaim() string {
return u.UsernameClaim
}
func (u *TestUpstreamOIDCIdentityProvider) GetGroupsClaim() string {
return u.GroupsClaim
}
func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokens(
ctx context.Context,
authcode string,
pkceCodeVerifier pkce.Code,
expectedIDTokenNonce nonce.Nonce,
redirectURI string,
) (oidctypes.Token, map[string]interface{}, error) {
if u.exchangeAuthcodeAndValidateTokensArgs == nil {
u.exchangeAuthcodeAndValidateTokensArgs = make([]*ExchangeAuthcodeAndValidateTokenArgs, 0)
}
u.exchangeAuthcodeAndValidateTokensCallCount++
u.exchangeAuthcodeAndValidateTokensArgs = append(u.exchangeAuthcodeAndValidateTokensArgs, &ExchangeAuthcodeAndValidateTokenArgs{
Ctx: ctx,
Authcode: authcode,
PKCECodeVerifier: pkceCodeVerifier,
ExpectedIDTokenNonce: expectedIDTokenNonce,
RedirectURI: redirectURI,
})
return u.ExchangeAuthcodeAndValidateTokensFunc(ctx, authcode, pkceCodeVerifier, expectedIDTokenNonce)
}
func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokensCallCount() int {
return u.exchangeAuthcodeAndValidateTokensCallCount
}
func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokensArgs(call int) *ExchangeAuthcodeAndValidateTokenArgs {
if u.exchangeAuthcodeAndValidateTokensArgs == nil {
u.exchangeAuthcodeAndValidateTokensArgs = make([]*ExchangeAuthcodeAndValidateTokenArgs, 0)
}
return u.exchangeAuthcodeAndValidateTokensArgs[call]
}
func (u *TestUpstreamOIDCIdentityProvider) ValidateToken(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (oidctypes.Token, map[string]interface{}, error) {
panic("implement me")
}
func NewIDPListGetter(upstreamOIDCIdentityProviders ...*TestUpstreamOIDCIdentityProvider) provider.DynamicUpstreamIDPProvider {
idpProvider := provider.NewDynamicUpstreamIDPProvider()
upstreams := make([]provider.UpstreamOIDCIdentityProviderI, len(upstreamOIDCIdentityProviders))
for i := range upstreamOIDCIdentityProviders {
upstreams[i] = provider.UpstreamOIDCIdentityProviderI(upstreamOIDCIdentityProviders[i])
}
idpProvider.SetIDPList(upstreams)
return idpProvider
}
// Declare a separate type from the production code to ensure that the state param's contents was serialized
// in the format that we expect, with the json keys that we expect, etc. This also ensure that the order of
// the serialized fields is the same, which doesn't really matter expect that we can make simpler equality
// assertions about the redirect URL in this test.
type ExpectedUpstreamStateParamFormat struct {
P string `json:"p"`
U string `json:"u"`
N string `json:"n"`
C string `json:"c"`
K string `json:"k"`
V string `json:"v"`
}
@@ -4,48 +4,73 @@
package provider
import (
"context"
"net/url"
"sync"
"golang.org/x/oauth2"
"go.pinniped.dev/pkg/oidcclient/nonce"
"go.pinniped.dev/pkg/oidcclient/oidctypes"
"go.pinniped.dev/pkg/oidcclient/pkce"
)
type UpstreamOIDCIdentityProvider struct {
type UpstreamOIDCIdentityProviderI interface {
// A name for this upstream provider, which will be used as a component of the path for the callback endpoint
// hosted by the Supervisor.
Name string
GetName() string
// The Oauth client ID registered with the upstream provider to be used in the authorization flow.
ClientID string
// The Oauth client ID registered with the upstream provider to be used in the authorization code flow.
GetClientID() string
// The Authorization Endpoint fetched from discovery.
AuthorizationURL url.URL
GetAuthorizationURL() *url.URL
// Scopes to request in authorization flow.
Scopes []string
GetScopes() []string
// ID Token username claim name. May return empty string, in which case we will use some reasonable defaults.
GetUsernameClaim() string
// ID Token groups claim name. May return empty string, in which case we won't try to read groups from the upstream provider.
GetGroupsClaim() string
// Performs upstream OIDC authorization code exchange and token validation.
// Returns the validated raw tokens as well as the parsed claims of the ID token.
ExchangeAuthcodeAndValidateTokens(
ctx context.Context,
authcode string,
pkceCodeVerifier pkce.Code,
expectedIDTokenNonce nonce.Nonce,
redirectURI string,
) (tokens oidctypes.Token, parsedIDTokenClaims map[string]interface{}, err error)
ValidateToken(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (oidctypes.Token, map[string]interface{}, error)
}
type DynamicUpstreamIDPProvider interface {
SetIDPList(oidcIDPs []UpstreamOIDCIdentityProvider)
GetIDPList() []UpstreamOIDCIdentityProvider
SetIDPList(oidcIDPs []UpstreamOIDCIdentityProviderI)
GetIDPList() []UpstreamOIDCIdentityProviderI
}
type dynamicUpstreamIDPProvider struct {
oidcProviders []UpstreamOIDCIdentityProvider
oidcProviders []UpstreamOIDCIdentityProviderI
mutex sync.RWMutex
}
func NewDynamicUpstreamIDPProvider() DynamicUpstreamIDPProvider {
return &dynamicUpstreamIDPProvider{
oidcProviders: []UpstreamOIDCIdentityProvider{},
oidcProviders: []UpstreamOIDCIdentityProviderI{},
}
}
func (p *dynamicUpstreamIDPProvider) SetIDPList(oidcIDPs []UpstreamOIDCIdentityProvider) {
func (p *dynamicUpstreamIDPProvider) SetIDPList(oidcIDPs []UpstreamOIDCIdentityProviderI) {
p.mutex.Lock() // acquire a write lock
defer p.mutex.Unlock()
p.oidcProviders = oidcIDPs
}
func (p *dynamicUpstreamIDPProvider) GetIDPList() []UpstreamOIDCIdentityProvider {
func (p *dynamicUpstreamIDPProvider) GetIDPList() []UpstreamOIDCIdentityProviderI {
p.mutex.RLock() // acquire a read lock
defer p.mutex.RUnlock()
return p.oidcProviders
+41 -15
View File
@@ -9,9 +9,11 @@ import (
"sync"
"github.com/gorilla/securecookie"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/auth"
"go.pinniped.dev/internal/oidc/callback"
"go.pinniped.dev/internal/oidc/csrftoken"
"go.pinniped.dev/internal/oidc/discovery"
"go.pinniped.dev/internal/oidc/jwks"
@@ -30,19 +32,26 @@ type Manager struct {
providerHandlers map[string]http.Handler // map of all routes for all providers
nextHandler http.Handler // the next handler in a chain, called when this manager didn't know how to handle a request
dynamicJWKSProvider jwks.DynamicJWKSProvider // in-memory cache of per-issuer JWKS data
idpListGetter auth.IDPListGetter // in-memory cache of upstream IDPs
idpListGetter oidc.IDPListGetter // in-memory cache of upstream IDPs
secretsClient corev1client.SecretInterface
}
// NewManager returns an empty Manager.
// nextHandler will be invoked for any requests that could not be handled by this manager's providers.
// dynamicJWKSProvider will be used as an in-memory cache for per-issuer JWKS data.
// idpListGetter will be used as an in-memory cache of currently configured upstream IDPs.
func NewManager(nextHandler http.Handler, dynamicJWKSProvider jwks.DynamicJWKSProvider, idpListGetter auth.IDPListGetter) *Manager {
func NewManager(
nextHandler http.Handler,
dynamicJWKSProvider jwks.DynamicJWKSProvider,
idpListGetter oidc.IDPListGetter,
secretsClient corev1client.SecretInterface,
) *Manager {
return &Manager{
providerHandlers: make(map[string]http.Handler),
nextHandler: nextHandler,
dynamicJWKSProvider: dynamicJWKSProvider,
idpListGetter: idpListGetter,
secretsClient: secretsClient,
}
}
@@ -62,20 +71,17 @@ func (m *Manager) SetProviders(oidcProviders ...*provider.OIDCProvider) {
m.providerHandlers = make(map[string]http.Handler)
for _, incomingProvider := range oidcProviders {
wellKnownURL := strings.ToLower(incomingProvider.IssuerHost()) + "/" + incomingProvider.IssuerPath() + oidc.WellKnownEndpointPath
m.providerHandlers[wellKnownURL] = discovery.NewHandler(incomingProvider.Issuer())
issuer := incomingProvider.Issuer()
issuerHostWithPath := strings.ToLower(incomingProvider.IssuerHost()) + "/" + incomingProvider.IssuerPath()
jwksURL := strings.ToLower(incomingProvider.IssuerHost()) + "/" + incomingProvider.IssuerPath() + oidc.JWKSEndpointPath
m.providerHandlers[jwksURL] = jwks.NewHandler(incomingProvider.Issuer(), m.dynamicJWKSProvider)
fositeHMACSecretForThisProvider := []byte("some secret - must have at least 32 bytes") // TODO replace this secret
// Use NullStorage for the authorize endpoint because we do not actually want to store anything until
// the upstream callback endpoint is called later.
oauthHelper := oidc.FositeOauth2Helper(
incomingProvider.Issuer(),
oidc.NullStorage{},
[]byte("some secret - must have at least 32 bytes"), // TODO replace this secret
nil, // TODO: inject me properly
)
oauthHelperWithNullStorage := oidc.FositeOauth2Helper(oidc.NullStorage{}, issuer, fositeHMACSecretForThisProvider, nil)
// For all the other endpoints, make another oauth helper with exactly the same settings except use real storage.
oauthHelperWithKubeStorage := oidc.FositeOauth2Helper(oidc.NewKubeStorage(m.secretsClient), issuer, fositeHMACSecretForThisProvider, nil)
// TODO use different codecs for the state and the cookie, because:
// 1. we would like to state to have an embedded expiration date while the cookie does not need that
@@ -86,10 +92,30 @@ func (m *Manager) SetProviders(oidcProviders ...*provider.OIDCProvider) {
var encoder = securecookie.New(encoderHashKey, encoderBlockKey)
encoder.SetSerializer(securecookie.JSONEncoder{})
authURL := strings.ToLower(incomingProvider.IssuerHost()) + "/" + incomingProvider.IssuerPath() + oidc.AuthorizationEndpointPath
m.providerHandlers[authURL] = auth.NewHandler(incomingProvider.Issuer(), m.idpListGetter, oauthHelper, csrftoken.Generate, pkce.Generate, nonce.Generate, encoder, encoder)
m.providerHandlers[(issuerHostWithPath + oidc.WellKnownEndpointPath)] = discovery.NewHandler(issuer)
plog.Debug("oidc provider manager added or updated issuer", "issuer", incomingProvider.Issuer())
m.providerHandlers[(issuerHostWithPath + oidc.JWKSEndpointPath)] = jwks.NewHandler(issuer, m.dynamicJWKSProvider)
m.providerHandlers[(issuerHostWithPath + oidc.AuthorizationEndpointPath)] = auth.NewHandler(
issuer,
m.idpListGetter,
oauthHelperWithNullStorage,
csrftoken.Generate,
pkce.Generate,
nonce.Generate,
encoder,
encoder,
)
m.providerHandlers[(issuerHostWithPath + oidc.CallbackEndpointPath)] = callback.NewHandler(
m.idpListGetter,
oauthHelperWithKubeStorage,
encoder,
encoder,
issuer+oidc.CallbackEndpointPath,
)
plog.Debug("oidc provider manager added or updated issuer", "issuer", issuer)
}
}
+94 -12
View File
@@ -4,6 +4,7 @@
package manager
import (
"context"
"encoding/json"
"io/ioutil"
"net/http"
@@ -15,12 +16,17 @@ import (
"github.com/sclevine/spec"
"github.com/stretchr/testify/require"
"gopkg.in/square/go-jose.v2"
"k8s.io/client-go/kubernetes/fake"
"go.pinniped.dev/internal/here"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/discovery"
"go.pinniped.dev/internal/oidc/jwks"
"go.pinniped.dev/internal/oidc/oidctestutil"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/pkg/oidcclient/nonce"
"go.pinniped.dev/pkg/oidcclient/oidctypes"
"go.pinniped.dev/pkg/oidcclient/pkce"
)
func TestManager(t *testing.T) {
@@ -31,6 +37,7 @@ func TestManager(t *testing.T) {
nextHandler http.HandlerFunc
fallbackHandlerWasCalled bool
dynamicJWKSProvider jwks.DynamicJWKSProvider
kubeClient *fake.Clientset
)
const (
@@ -41,6 +48,7 @@ func TestManager(t *testing.T) {
issuer2DifferentCaseHostname = "https://exAmPlE.Com/some/path/more/deeply/nested/path"
issuer2KeyID = "issuer2-key"
upstreamIDPAuthorizationURL = "https://test-upstream.com/auth"
downstreamRedirectURL = "http://127.0.0.1:12345/callback"
)
newGetRequest := func(url string) *http.Request {
@@ -64,7 +72,7 @@ func TestManager(t *testing.T) {
r.Equal(expectedIssuerInResponse, parsedDiscoveryResult.Issuer)
}
requireAuthorizationRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedRedirectLocationPrefix string) {
requireAuthorizationRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedRedirectLocationPrefix string) (string, string) {
recorder := httptest.NewRecorder()
subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.AuthorizationEndpointPath+requestURLSuffix))
@@ -79,6 +87,58 @@ func TestManager(t *testing.T) {
"actual location %s did not start with expected prefix %s",
actualLocation, expectedRedirectLocationPrefix,
)
parsedLocation, err := url.Parse(actualLocation)
r.NoError(err)
redirectStateParam := parsedLocation.Query().Get("state")
r.NotEmpty(redirectStateParam)
cookieValueAndDirectivesSplit := strings.SplitN(recorder.Header().Get("Set-Cookie"), ";", 2)
r.Len(cookieValueAndDirectivesSplit, 2)
cookieKeyValueSplit := strings.Split(cookieValueAndDirectivesSplit[0], "=")
r.Len(cookieKeyValueSplit, 2)
csrfCookieName := cookieKeyValueSplit[0]
r.Equal("__Host-pinniped-csrf", csrfCookieName)
csrfCookieValue := cookieKeyValueSplit[1]
r.NotEmpty(csrfCookieValue)
// Return the important parts of the response so we can use them in our next request to the callback endpoint
return csrfCookieValue, redirectStateParam
}
requireCallbackRequestToBeHandled := func(requestIssuer, requestURLSuffix, csrfCookieValue string) {
recorder := httptest.NewRecorder()
numberOfKubeActionsBeforeThisRequest := len(kubeClient.Actions())
getRequest := newGetRequest(requestIssuer + oidc.CallbackEndpointPath + requestURLSuffix)
getRequest.AddCookie(&http.Cookie{
Name: "__Host-pinniped-csrf",
Value: csrfCookieValue,
})
subject.ServeHTTP(recorder, getRequest)
r.False(fallbackHandlerWasCalled)
// Check just enough of the response to ensure that we wired up the callback endpoint correctly.
// The endpoint's own unit tests cover everything else.
r.Equal(http.StatusFound, recorder.Code)
actualLocation := recorder.Header().Get("Location")
r.True(
strings.HasPrefix(actualLocation, downstreamRedirectURL),
"actual location %s did not start with expected prefix %s",
actualLocation, downstreamRedirectURL,
)
parsedLocation, err := url.Parse(actualLocation)
r.NoError(err)
actualLocationQueryParams := parsedLocation.Query()
r.Contains(actualLocationQueryParams, "code")
r.Equal("openid", actualLocationQueryParams.Get("scope"))
r.Equal("some-state-value-that-is-32-byte", actualLocationQueryParams.Get("state"))
// Make sure that we wired up the callback endpoint to use kube storage for fosite sessions.
r.Equal(len(kubeClient.Actions()), numberOfKubeActionsBeforeThisRequest+3,
"did not perform any kube actions during the callback request, but should have")
}
requireJWKSRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedJWKKeyID string) {
@@ -107,17 +167,27 @@ func TestManager(t *testing.T) {
parsedUpstreamIDPAuthorizationURL, err := url.Parse(upstreamIDPAuthorizationURL)
r.NoError(err)
idpListGetter := provider.NewDynamicUpstreamIDPProvider()
idpListGetter.SetIDPList([]provider.UpstreamOIDCIdentityProvider{
{
Name: "test-idp",
ClientID: "test-client-id",
AuthorizationURL: *parsedUpstreamIDPAuthorizationURL,
Scopes: []string{"test-scope"},
idpListGetter := oidctestutil.NewIDPListGetter(&oidctestutil.TestUpstreamOIDCIdentityProvider{
Name: "test-idp",
ClientID: "test-client-id",
AuthorizationURL: *parsedUpstreamIDPAuthorizationURL,
Scopes: []string{"test-scope"},
ExchangeAuthcodeAndValidateTokensFunc: func(ctx context.Context, authcode string, pkceCodeVerifier pkce.Code, expectedIDTokenNonce nonce.Nonce) (oidctypes.Token, map[string]interface{}, error) {
return oidctypes.Token{},
map[string]interface{}{
"iss": "https://some-issuer.com",
"sub": "some-subject",
"username": "test-username",
"groups": "test-group1",
},
nil
},
})
subject = NewManager(nextHandler, dynamicJWKSProvider, idpListGetter)
kubeClient = fake.NewSimpleClientset()
secretsClient := kubeClient.CoreV1().Secrets("some-namespace")
subject = NewManager(nextHandler, dynamicJWKSProvider, idpListGetter, secretsClient)
})
when("given no providers via SetProviders()", func() {
@@ -164,7 +234,6 @@ func TestManager(t *testing.T) {
requireJWKSRequestToBeHandled(issuer2DifferentCaseHostname, "", issuer2KeyID)
requireJWKSRequestToBeHandled(issuer2DifferentCaseHostname, "?some=query", issuer2KeyID)
authRedirectURI := "http://127.0.0.1/callback"
authRequestParams := "?" + url.Values{
"response_type": []string{"code"},
"scope": []string{"openid profile email"},
@@ -173,7 +242,7 @@ func TestManager(t *testing.T) {
"nonce": []string{"some-nonce-value"},
"code_challenge": []string{"some-challenge"},
"code_challenge_method": []string{"S256"},
"redirect_uri": []string{authRedirectURI},
"redirect_uri": []string{downstreamRedirectURL},
}.Encode()
requireAuthorizationRequestToBeHandled(issuer1, authRequestParams, upstreamIDPAuthorizationURL)
@@ -181,7 +250,20 @@ func TestManager(t *testing.T) {
// Hostnames are case-insensitive, so test that we can handle that.
requireAuthorizationRequestToBeHandled(issuer1DifferentCaseHostname, authRequestParams, upstreamIDPAuthorizationURL)
requireAuthorizationRequestToBeHandled(issuer2DifferentCaseHostname, authRequestParams, upstreamIDPAuthorizationURL)
csrfCookieValue, upstreamStateParam :=
requireAuthorizationRequestToBeHandled(issuer2DifferentCaseHostname, authRequestParams, upstreamIDPAuthorizationURL)
callbackRequestParams := "?" + url.Values{
"code": []string{"some-fake-code"},
"state": []string{upstreamStateParam},
}.Encode()
requireCallbackRequestToBeHandled(issuer1, callbackRequestParams, csrfCookieValue)
requireCallbackRequestToBeHandled(issuer2, callbackRequestParams, csrfCookieValue)
// // Hostnames are case-insensitive, so test that we can handle that.
requireCallbackRequestToBeHandled(issuer1DifferentCaseHostname, callbackRequestParams, csrfCookieValue)
requireCallbackRequestToBeHandled(issuer2DifferentCaseHostname, callbackRequestParams, csrfCookieValue)
}
when("given some valid providers via SetProviders()", func() {