Boundary OIDC method does not currently support passing in prompts during authentication. This change adds the capability of passing OIDC prompts. Prompts are optional OIDC parameters that determine the behaviour of the authentication server: https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest ## Changes - New `auth_oidc_prompt` table which contains all the prompts for OIDC auth method - New `auth_oidc_prompt_enm` table which contains possible enum values for a prompt - Currently supported: - `none`: The Authorization Server MUST NOT display any authentication or consent user interface pages - `login`: The Authorization Server SHOULD prompt the End-User for reauthentication - `consent`: The Authorization Server SHOULD prompt the End-User for consent before returning information to the Client - `select_account`: The Authorization Server SHOULD prompt the End-User to select a user account - `oidc_auth_method_with_value_obj` view has been updated to return `prompt` value - Add `prompt` option for OIDC auth method CLI create and update - Pass configured prompt during OIDC authentication - Add `prompt` API validation for create and update
279 lines
9.3 KiB
Go
279 lines
9.3 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package oidc
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hashicorp/boundary/internal/auth/oidc/request"
|
|
"github.com/hashicorp/boundary/internal/db"
|
|
"github.com/hashicorp/boundary/internal/errors"
|
|
"github.com/hashicorp/boundary/internal/iam"
|
|
"github.com/hashicorp/boundary/internal/kms"
|
|
"github.com/hashicorp/cap/oidc"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
func Test_StartAuth(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
tp := oidc.StartTestProvider(t)
|
|
_, _, tpAlg, _ := tp.SigningKeys()
|
|
tpCert, err := ParseCertificates(ctx, tp.CACert())
|
|
require.NoError(t, err)
|
|
tpPrompt := []PromptParam{Consent, SelectAccount}
|
|
conn, _ := db.TestSetup(t, "postgres")
|
|
rw := db.New(conn)
|
|
rootWrapper := db.TestWrapper(t)
|
|
kmsCache := kms.TestKms(t, conn, rootWrapper)
|
|
testController := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
w.WriteHeader(200)
|
|
}))
|
|
defer testController.Close()
|
|
|
|
repoFn := func() (*Repository, error) {
|
|
return NewRepository(ctx, rw, rw, kmsCache)
|
|
}
|
|
org, _ := iam.TestScopes(t, iam.TestRepo(t, conn, rootWrapper))
|
|
databaseWrapper, err := kmsCache.GetWrapper(ctx, org.PublicId, kms.KeyPurposeDatabase)
|
|
require.NoError(t, err)
|
|
testAuthMethod := TestAuthMethod(
|
|
t, conn, databaseWrapper, org.PublicId, ActivePublicState,
|
|
"test-rp", "fido",
|
|
WithIssuer(TestConvertToUrls(t, tp.Addr())[0]),
|
|
WithApiUrl(TestConvertToUrls(t, testController.URL)[0]),
|
|
WithSigningAlgs(Alg(tpAlg)),
|
|
WithCertificates(tpCert...),
|
|
WithClaimsScopes("email", "profile"),
|
|
)
|
|
|
|
testAuthMethodInactive := TestAuthMethod(
|
|
t, conn, databaseWrapper, org.PublicId, InactiveState,
|
|
"test-rp2", "fido",
|
|
WithIssuer(TestConvertToUrls(t, tp.Addr())[0]),
|
|
WithApiUrl(TestConvertToUrls(t, testController.URL)[0]),
|
|
WithSigningAlgs(Alg(tpAlg)),
|
|
WithCertificates(tpCert...),
|
|
)
|
|
|
|
testAuthMethodWithMaxAge := TestAuthMethod(
|
|
t, conn, databaseWrapper, org.PublicId, ActivePublicState,
|
|
"test-rp3", "fido",
|
|
WithIssuer(TestConvertToUrls(t, tp.Addr())[0]),
|
|
WithApiUrl(TestConvertToUrls(t, testController.URL)[0]),
|
|
WithSigningAlgs(Alg(tpAlg)),
|
|
WithCertificates(tpCert...),
|
|
WithMaxAge(-1),
|
|
)
|
|
|
|
testAuthMethodWithPrompt := TestAuthMethod(
|
|
t, conn, databaseWrapper, org.PublicId, ActivePublicState,
|
|
"test-rp4", "fido",
|
|
WithIssuer(TestConvertToUrls(t, tp.Addr())[0]),
|
|
WithApiUrl(TestConvertToUrls(t, testController.URL)[0]),
|
|
WithSigningAlgs(Alg(tpAlg)),
|
|
WithCertificates(tpCert...),
|
|
WithPrompts(tpPrompt...),
|
|
)
|
|
|
|
stdSetup := func(am *AuthMethod, repoFn OidcRepoFactory, apiSrv *httptest.Server) (a *AuthMethod, allowedRedirect string) {
|
|
// update the allowed redirects for the TestProvider
|
|
tpAllowedRedirect := fmt.Sprintf(CallbackEndpoint, apiSrv.URL)
|
|
tp.SetAllowedRedirectURIs([]string{tpAllowedRedirect})
|
|
r, err := repoFn()
|
|
require.NoError(t, err)
|
|
// update the test's auth method, now that we've added a new callback
|
|
am, err = r.lookupAuthMethod(ctx, am.PublicId)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, am)
|
|
return am, tpAllowedRedirect
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
repoFn OidcRepoFactory
|
|
apiSrv *httptest.Server
|
|
authMethod *AuthMethod
|
|
roundTripper string
|
|
setup func(am *AuthMethod, repoFn OidcRepoFactory, apiSrv *httptest.Server) (*AuthMethod, string)
|
|
wantErrMatch *errors.Template
|
|
wantErrContains string
|
|
}{
|
|
{
|
|
name: "simple",
|
|
repoFn: repoFn,
|
|
apiSrv: testController,
|
|
authMethod: testAuthMethod,
|
|
setup: stdSetup,
|
|
},
|
|
{
|
|
name: "simple-with-roundtripper",
|
|
repoFn: repoFn,
|
|
apiSrv: testController,
|
|
authMethod: testAuthMethod,
|
|
setup: stdSetup,
|
|
roundTripper: `{"alice": "hi bob", "eve": "chuckles says hi alice"}`,
|
|
},
|
|
{
|
|
name: "simple-with-max-age",
|
|
repoFn: repoFn,
|
|
apiSrv: testController,
|
|
authMethod: testAuthMethodWithMaxAge,
|
|
setup: stdSetup,
|
|
},
|
|
{
|
|
name: "inactive",
|
|
repoFn: repoFn,
|
|
apiSrv: testController,
|
|
authMethod: testAuthMethodInactive,
|
|
wantErrMatch: errors.T(errors.AuthMethodInactive),
|
|
wantErrContains: "not allowed to start authentication attempt",
|
|
setup: stdSetup,
|
|
},
|
|
{
|
|
name: "missing-repo-func",
|
|
repoFn: nil,
|
|
apiSrv: testController,
|
|
authMethod: testAuthMethodInactive,
|
|
wantErrMatch: errors.T(errors.InvalidParameter),
|
|
wantErrContains: "missing oidc repo function",
|
|
},
|
|
{
|
|
name: "missing-authmethod-id",
|
|
repoFn: repoFn,
|
|
apiSrv: testController,
|
|
authMethod: func() *AuthMethod { am := AllocAuthMethod(); return &am }(),
|
|
wantErrMatch: errors.T(errors.InvalidParameter),
|
|
wantErrContains: "missing auth method id",
|
|
},
|
|
{
|
|
name: "bad-authmethod-id",
|
|
repoFn: repoFn,
|
|
apiSrv: testController,
|
|
authMethod: func() *AuthMethod { am := AllocAuthMethod(); am.PublicId = "not-valid"; return &am }(),
|
|
wantErrMatch: errors.T(errors.RecordNotFound),
|
|
wantErrContains: "auth method not-valid not found:",
|
|
},
|
|
{
|
|
name: "simple-with-prompt",
|
|
repoFn: repoFn,
|
|
apiSrv: testController,
|
|
authMethod: testAuthMethodWithPrompt,
|
|
setup: stdSetup,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert, require := assert.New(t), require.New(t)
|
|
var allowedRedirect string
|
|
if tt.setup != nil {
|
|
tt.authMethod, allowedRedirect = tt.setup(tt.authMethod, tt.repoFn, tt.apiSrv)
|
|
}
|
|
|
|
now := time.Now()
|
|
authUrl, tokenId, err := StartAuth(ctx, tt.repoFn, tt.authMethod.PublicId, WithRoundtripPayload(tt.roundTripper))
|
|
if tt.wantErrMatch != nil {
|
|
require.Error(err)
|
|
assert.Nil(authUrl)
|
|
assert.Truef(errors.Match(tt.wantErrMatch, err), "want err code: %q got: %q", tt.wantErrMatch, err)
|
|
assert.Contains(err.Error(), tt.wantErrContains)
|
|
return
|
|
}
|
|
require.NoError(err)
|
|
require.NotNil(authUrl)
|
|
|
|
authParams, err := url.ParseQuery(authUrl.RawQuery)
|
|
require.NoError(err)
|
|
assert.Equal(authParams["response_type"], []string{"code"})
|
|
assert.Equal(authParams["response_type"], []string{"code"})
|
|
assert.Equal(authParams["redirect_uri"], []string{allowedRedirect})
|
|
assert.Equal(authParams["client_id"], []string{tt.authMethod.ClientId})
|
|
require.Equal(1, len(authParams["nonce"]))
|
|
require.Equal(1, len(authParams["state"]))
|
|
|
|
require.Len(authParams["scope"], 1)
|
|
wantClaimsScopes := append(tt.authMethod.ClaimsScopes, DefaultClaimsScope)
|
|
gotScopes := strings.Split(authParams["scope"][0], " ")
|
|
sort.Strings(gotScopes)
|
|
sort.Strings(wantClaimsScopes)
|
|
assert.Equal(wantClaimsScopes, gotScopes)
|
|
|
|
if tt.authMethod.MaxAge != 0 {
|
|
var expected int
|
|
switch {
|
|
case tt.authMethod.MaxAge == -1:
|
|
expected = 0
|
|
default:
|
|
expected = int(tt.authMethod.MaxAge)
|
|
}
|
|
gotSlice, ok := authParams["max_age"]
|
|
require.True(ok)
|
|
require.Equal(1, len(gotSlice))
|
|
got, err := strconv.Atoi(gotSlice[0])
|
|
require.NoError(err)
|
|
assert.Equal(expected, got)
|
|
}
|
|
|
|
// verify the state in the authUrl can be decrypted and it's correct
|
|
state := authParams["state"][0]
|
|
wrappedStReq, err := UnwrapMessage(ctx, state)
|
|
require.NoError(err)
|
|
repo, err := tt.repoFn()
|
|
require.NoError(err)
|
|
wrappingWrapper, err := requestWrappingWrapper(ctx, repo.kms, wrappedStReq.ScopeId, wrappedStReq.AuthMethodId)
|
|
require.NoError(err)
|
|
decryptedBytes, err := decryptMessage(ctx, wrappingWrapper, wrappedStReq)
|
|
require.NoError(err)
|
|
var reqState request.State
|
|
err = proto.Unmarshal(decryptedBytes, &reqState)
|
|
require.NoError(err)
|
|
assert.NotNil(reqState.CreateTime)
|
|
assert.NotEmpty(reqState.FinalRedirectUrl)
|
|
switch tt.roundTripper != "" {
|
|
case true:
|
|
u := make(url.Values)
|
|
u.Add("roundtrip_payload", tt.roundTripper)
|
|
assert.Equal(fmt.Sprintf(FinalRedirectEndpoint, tt.apiSrv.URL)+"?"+u.Encode(), reqState.FinalRedirectUrl)
|
|
assert.Contains(reqState.FinalRedirectUrl, u.Encode())
|
|
default:
|
|
assert.Equal(fmt.Sprintf(FinalRedirectEndpoint, tt.apiSrv.URL), reqState.FinalRedirectUrl)
|
|
}
|
|
assert.Equal(authParams["nonce"][0], reqState.Nonce)
|
|
|
|
assert.WithinDuration(reqState.CreateTime.Timestamp.AsTime(), now, 1*time.Second)
|
|
assert.WithinDuration(reqState.ExpirationTime.Timestamp.AsTime(), now.Add(AttemptExpiration), 1*time.Second)
|
|
|
|
p, err := convertToProvider(ctx, tt.authMethod)
|
|
require.NoError(err)
|
|
configHash, err := p.ConfigHash()
|
|
require.NoError(err)
|
|
assert.Equal(configHash, reqState.ProviderConfigHash)
|
|
|
|
// verify the token_id in the tokenUrl can be decrypted and it's correct
|
|
wrappedTkReq, err := UnwrapMessage(ctx, tokenId)
|
|
require.NoError(err)
|
|
wrappingWrapper, err = requestWrappingWrapper(ctx, repo.kms, wrappedTkReq.ScopeId, wrappedTkReq.AuthMethodId)
|
|
require.NoError(err)
|
|
decryptedBytes, err = decryptMessage(ctx, wrappingWrapper, wrappedTkReq)
|
|
require.NoError(err)
|
|
var reqToken request.Token
|
|
err = proto.Unmarshal(decryptedBytes, &reqToken)
|
|
require.NoError(err)
|
|
|
|
assert.Equal(reqState.TokenRequestId, reqToken.RequestId)
|
|
assert.Equal(reqState.ExpirationTime, reqToken.ExpirationTime)
|
|
})
|
|
}
|
|
}
|