2020-11-13 17:31:39 +00:00
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package callback
import (
2020-11-13 23:59:51 +00:00
"fmt"
2020-11-16 22:07:34 +00:00
"html"
2020-11-13 17:31:39 +00:00
"net/http"
"net/http/httptest"
2020-11-13 23:59:51 +00:00
"net/url"
2020-11-16 22:07:34 +00:00
"regexp"
2020-11-13 17:31:39 +00:00
"testing"
2020-11-13 23:59:51 +00:00
"github.com/gorilla/securecookie"
2020-11-13 17:31:39 +00:00
"github.com/stretchr/testify/require"
2020-11-13 23:59:51 +00:00
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/testutil"
)
const (
happyUpstreamIDPName = "upstream-idp-name"
2020-11-13 17:31:39 +00:00
)
func TestCallbackEndpoint ( t * testing . T ) {
2020-11-16 22:07:34 +00:00
const (
downstreamRedirectURI = "http://127.0.0.1/callback"
)
2020-11-13 23:59:51 +00:00
upstreamAuthURL , err := url . Parse ( "https://some-upstream-idp:8443/auth" )
require . NoError ( t , err )
otherUpstreamAuthURL , err := url . Parse ( "https://some-other-upstream-idp:8443/auth" )
require . NoError ( t , err )
upstreamOIDCIdentityProvider := provider . UpstreamOIDCIdentityProvider {
Name : happyUpstreamIDPName ,
ClientID : "some-client-id" ,
AuthorizationURL : * upstreamAuthURL ,
Scopes : [ ] string { "scope1" , "scope2" } ,
}
otherUpstreamOIDCIdentityProvider := provider . UpstreamOIDCIdentityProvider {
Name : "other-upstream-idp-name" ,
ClientID : "other-some-client-id" ,
AuthorizationURL : * otherUpstreamAuthURL ,
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 )
2020-11-16 19:41:00 +00:00
var happyStateCodec = securecookie . New ( stateEncoderHashKey , stateEncoderBlockKey )
happyStateCodec . SetSerializer ( securecookie . JSONEncoder { } )
var happyCookieCodec = securecookie . New ( cookieEncoderHashKey , cookieEncoderBlockKey )
happyCookieCodec . SetSerializer ( securecookie . JSONEncoder { } )
2020-11-16 22:07:34 +00:00
happyDownstreamState := "some-downstream-state"
happyOrignalRequestParams := url . Values {
"response_type" : [ ] string { "code" } ,
"scope" : [ ] string { "openid profile email" } ,
"client_id" : [ ] string { "pinniped-cli" } ,
"state" : [ ] string { happyDownstreamState } ,
"nonce" : [ ] string { "some-nonce-value" } ,
"code_challenge" : [ ] string { "some-challenge" } ,
"code_challenge_method" : [ ] string { "S256" } ,
"redirect_uri" : [ ] string { downstreamRedirectURI } ,
} . Encode ( )
2020-11-16 19:41:00 +00:00
happyCSRF := "test-csrf"
happyPKCE := "test-pkce"
happyNonce := "test-nonce"
happyState , err := happyStateCodec . Encode ( "s" ,
testutil . ExpectedUpstreamStateParamFormat {
2020-11-16 22:07:34 +00:00
P : happyOrignalRequestParams ,
2020-11-16 19:41:00 +00:00
N : happyNonce ,
C : happyCSRF ,
K : happyPKCE ,
V : "1" ,
} ,
)
require . NoError ( t , err )
wrongCSRFValueState , err := happyStateCodec . Encode ( "s" ,
testutil . ExpectedUpstreamStateParamFormat {
2020-11-16 22:07:34 +00:00
P : happyOrignalRequestParams ,
2020-11-16 19:41:00 +00:00
N : happyNonce ,
C : "wrong-csrf-value" ,
K : happyPKCE ,
V : "1" ,
} ,
)
require . NoError ( t , err )
wrongVersionState , err := happyStateCodec . Encode ( "s" ,
testutil . ExpectedUpstreamStateParamFormat {
2020-11-16 22:07:34 +00:00
P : happyOrignalRequestParams ,
2020-11-16 19:41:00 +00:00
N : happyNonce ,
C : happyCSRF ,
K : happyPKCE ,
V : "wrong-version" ,
} ,
)
require . NoError ( t , err )
encodedIncomingCookieCSRFValue , err := happyCookieCodec . Encode ( "csrf" , happyCSRF )
2020-11-16 16:47:49 +00:00
require . NoError ( t , err )
happyCSRFCookie := "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue
2020-11-13 23:59:51 +00:00
2020-11-13 17:31:39 +00:00
tests := [ ] struct {
name string
2020-11-16 16:47:49 +00:00
idpListGetter provider . DynamicUpstreamIDPProvider
2020-11-13 23:59:51 +00:00
method string
path string
2020-11-16 16:47:49 +00:00
csrfCookie string
2020-11-13 17:31:39 +00:00
2020-11-16 22:07:34 +00:00
wantStatus int
wantBody string
wantRedirectLocationRegexp string
2020-11-13 17:31:39 +00:00
} {
// Happy path
// TODO: GET with good state and cookie and successful upstream token exchange and 302 to downstream client callback with its state and code
2020-11-16 22:07:34 +00:00
{
name : "GET with good state and cookie and successful upstream token exchange returns 302 to downstream client callback with its state and code" ,
idpListGetter : testutil . NewIDPListGetter ( upstreamOIDCIdentityProvider ) ,
method : http . MethodGet ,
path : newRequestPath ( ) . WithState ( happyState ) . String ( ) ,
csrfCookie : happyCSRFCookie ,
wantStatus : http . StatusFound ,
wantRedirectLocationRegexp : downstreamRedirectURI + ` \?code=([^&]+)&state= ` + happyDownstreamState ,
} ,
// TODO: when we call the callback twice in a row, we get two different auth codes (to prove we are using an RNG for auth codes)
2020-11-13 17:31:39 +00:00
// Pre-upstream-exchange verification
{
name : "PUT method is invalid" ,
method : http . MethodPut ,
2020-11-13 23:59:51 +00:00
path : newRequestPath ( ) . String ( ) ,
2020-11-13 17:31:39 +00:00
wantStatus : http . StatusMethodNotAllowed ,
wantBody : "Method Not Allowed: PUT (try GET)\n" ,
} ,
2020-11-13 23:59:51 +00:00
{
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" ,
} ,
{
2020-11-16 19:41:00 +00:00
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" ,
2020-11-13 23:59:51 +00:00
} ,
{
2020-11-16 19:41:00 +00:00
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" ,
2020-11-13 23:59:51 +00:00
} ,
{
name : "state param was not signed correctly, has expired, or otherwise cannot be decoded for any reason" ,
2020-11-16 16:47:49 +00:00
idpListGetter : testutil . NewIDPListGetter ( upstreamOIDCIdentityProvider ) ,
2020-11-13 23:59:51 +00:00
method : http . MethodGet ,
path : newRequestPath ( ) . WithState ( "this-will-not-decode" ) . String ( ) ,
2020-11-16 16:47:49 +00:00
csrfCookie : happyCSRFCookie ,
2020-11-13 23:59:51 +00:00
wantStatus : http . StatusBadRequest ,
2020-11-16 19:41:00 +00:00
wantBody : "Bad Request: error reading state\n" ,
} ,
{
name : "state's internal version does not match what we want" ,
idpListGetter : testutil . NewIDPListGetter ( upstreamOIDCIdentityProvider ) ,
method : http . MethodGet ,
path : newRequestPath ( ) . WithState ( wrongVersionState ) . String ( ) ,
csrfCookie : happyCSRFCookie ,
wantStatus : http . StatusUnprocessableEntity ,
wantBody : "Unprocessable Entity: state format version is invalid\n" ,
2020-11-13 23:59:51 +00:00
} ,
{
name : "the UpstreamOIDCProvider CRD has been deleted" ,
2020-11-16 16:47:49 +00:00
idpListGetter : testutil . NewIDPListGetter ( otherUpstreamOIDCIdentityProvider ) ,
2020-11-13 23:59:51 +00:00
method : http . MethodGet ,
2020-11-16 19:41:00 +00:00
path : newRequestPath ( ) . WithState ( happyState ) . String ( ) ,
2020-11-16 16:47:49 +00:00
csrfCookie : happyCSRFCookie ,
2020-11-13 23:59:51 +00:00
wantStatus : http . StatusUnprocessableEntity ,
wantBody : "Unprocessable Entity: upstream provider not found\n" ,
} ,
2020-11-16 16:47:49 +00:00
{
name : "the CSRF cookie does not exist on request" ,
idpListGetter : testutil . NewIDPListGetter ( otherUpstreamOIDCIdentityProvider ) ,
method : http . MethodGet ,
2020-11-16 19:41:00 +00:00
path : newRequestPath ( ) . WithState ( happyState ) . String ( ) ,
2020-11-16 16:47:49 +00:00
wantStatus : http . StatusForbidden ,
2020-11-16 19:41:00 +00:00
wantBody : "Forbidden: CSRF cookie is missing\n" ,
2020-11-16 16:47:49 +00:00
} ,
{
2020-11-16 19:41:00 +00:00
name : "cookie was not signed correctly, has expired, or otherwise cannot be decoded for any reason" ,
2020-11-16 16:47:49 +00:00
idpListGetter : testutil . NewIDPListGetter ( otherUpstreamOIDCIdentityProvider ) ,
method : http . MethodGet ,
2020-11-16 19:41:00 +00:00
path : newRequestPath ( ) . WithState ( happyState ) . String ( ) ,
2020-11-16 16:47:49 +00:00
csrfCookie : "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped" ,
wantStatus : http . StatusForbidden ,
2020-11-16 19:41:00 +00:00
wantBody : "Forbidden: error reading CSRF cookie\n" ,
} ,
{
name : "cookie csrf value does not match state csrf value" ,
idpListGetter : testutil . NewIDPListGetter ( otherUpstreamOIDCIdentityProvider ) ,
method : http . MethodGet ,
path : newRequestPath ( ) . WithState ( wrongCSRFValueState ) . String ( ) ,
csrfCookie : happyCSRFCookie ,
wantStatus : http . StatusForbidden ,
wantBody : "Forbidden: CSRF value does not match\n" ,
2020-11-16 16:47:49 +00:00
} ,
2020-11-13 17:31:39 +00:00
// Upstream exchange
// TODO: network call to upstream token endpoint fails
// TODO: the upstream token endpoint returns an error
// Post-upstream-exchange verification
// TODO: returned tokens are invalid (all the stuff from the spec...)
// TODO: there
// TODO: are
// TODO: probably
// TODO: a
// TODO: lot
// TODO: of
// TODO: test
// TODO: cases
// TODO: here (e.g., id jwt cannot be verified, nonce is wrong, we didn't get refresh token, we didn't get access token, we didn't get id token, access token expires too quickly)
// Downstream redirect
2020-11-13 23:59:51 +00:00
// TODO: we grant the openid scope if it was requested, similar to what we did in auth_handler.go
2020-11-13 17:31:39 +00:00
// TODO: cannot generate auth code
// TODO: cannot persist downstream state
}
for _ , test := range tests {
test := test
t . Run ( test . name , func ( t * testing . T ) {
2020-11-16 19:41:00 +00:00
subject := NewHandler ( test . idpListGetter , happyStateCodec , happyCookieCodec )
2020-11-13 23:59:51 +00:00
req := httptest . NewRequest ( test . method , test . path , nil )
2020-11-16 16:47:49 +00:00
if test . csrfCookie != "" {
req . Header . Set ( "Cookie" , test . csrfCookie )
}
2020-11-13 17:31:39 +00:00
rsp := httptest . NewRecorder ( )
subject . ServeHTTP ( rsp , req )
require . Equal ( t , test . wantStatus , rsp . Code )
2020-11-16 22:07:34 +00:00
require . False ( t , test . wantBody != "" && test . wantRedirectLocationRegexp != "" , "test cannot set both body and redirect assertions" )
switch {
case test . wantBody != "" :
require . Empty ( t , rsp . Header ( ) . Values ( "Location" ) )
require . Equal ( t , test . wantBody , rsp . Body . String ( ) )
case test . wantRedirectLocationRegexp != "" :
// 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 ]
_ = capturedAuthCode
// Assert capturedAuthCode storage stuff...
// Assert that body contains anchor tag with redirect location.
anchorTagWithLocationHref := fmt . Sprintf ( "<a href=\"%s\">Found</a>.\n\n" , html . EscapeString ( actualLocation ) )
require . Equal ( t , anchorTagWithLocationHref , rsp . Body . String ( ) )
default :
require . Empty ( t , rsp . Header ( ) . Values ( "Location" ) )
require . Empty ( t , rsp . Body . String ( ) )
}
2020-11-13 17:31:39 +00:00
} )
}
}
2020-11-13 23:59:51 +00:00
type requestPath struct {
upstreamIDPName , code , state * string
}
func newRequestPath ( ) * requestPath {
n := happyUpstreamIDPName
c := "1234"
s := "4321"
return & requestPath {
upstreamIDPName : & n ,
code : & c ,
state : & s ,
}
}
func ( r * requestPath ) WithUpstreamIDPName ( name string ) * requestPath {
r . upstreamIDPName = & name
return r
}
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 := fmt . Sprintf ( "/downstream-provider-name/callback/%s?" , * r . upstreamIDPName )
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 ( )
}