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
500 lines
17 KiB
Go
500 lines
17 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package authmethods
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/hashicorp/boundary/internal/auth/oidc"
|
|
oidcstore "github.com/hashicorp/boundary/internal/auth/oidc/store"
|
|
"github.com/hashicorp/boundary/internal/daemon/controller/auth"
|
|
"github.com/hashicorp/boundary/internal/daemon/controller/handlers"
|
|
"github.com/hashicorp/boundary/internal/errors"
|
|
"github.com/hashicorp/boundary/internal/event"
|
|
pbs "github.com/hashicorp/boundary/internal/gen/controller/api/services"
|
|
"github.com/hashicorp/boundary/internal/types/action"
|
|
pb "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/authmethods"
|
|
"google.golang.org/grpc/codes"
|
|
)
|
|
|
|
const (
|
|
// commands
|
|
startCommand = "start"
|
|
callbackCommand = "callback"
|
|
tokenCommand = "token"
|
|
|
|
// token request/response fields
|
|
statusField = "status"
|
|
|
|
// field names
|
|
issuerField = "attributes.issuer"
|
|
clientSecretField = "attributes.client_secret"
|
|
clientIdField = "attributes.client_id"
|
|
clientSecretHmacField = "attributes.client_secret_hmac"
|
|
stateField = "attributes.state"
|
|
callbackUrlField = "attributes.callback_url"
|
|
apiUrlPrefixField = "attributes.api_url_prefix"
|
|
idpCaCertsField = "attributes.idp_ca_certs"
|
|
signingAlgorithmField = "attributes.signing_algorithms"
|
|
disableDiscoveredConfigValidationField = "attributes.disable_discovered_config_validation"
|
|
roundtripPayloadAttributesField = "attributes.roundtrip_payload"
|
|
codeField = "attributes.code"
|
|
claimsScopesField = "attributes.claims_scopes"
|
|
accountClaimMapsField = "attributes.account_claim_maps"
|
|
promptsField = "attributes.prompts"
|
|
)
|
|
|
|
var oidcMaskManager handlers.MaskManager
|
|
|
|
func init() {
|
|
var err error
|
|
if oidcMaskManager, err = handlers.NewMaskManager(context.Background(), handlers.MaskDestination{&oidcstore.AuthMethod{}}, handlers.MaskSource{&pb.AuthMethod{}, &pb.OidcAuthMethodAttributes{}}); err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
IdActions[oidc.Subtype] = action.ActionSet{
|
|
action.NoOp,
|
|
action.Read,
|
|
action.Update,
|
|
action.Delete,
|
|
action.ChangeState,
|
|
action.Authenticate,
|
|
}
|
|
}
|
|
|
|
type oidcState uint
|
|
|
|
const (
|
|
_ oidcState = iota
|
|
inactiveState
|
|
privateState
|
|
publicState
|
|
)
|
|
|
|
var oidcStateMap = map[string]oidcState{
|
|
inactiveState.String(): inactiveState,
|
|
privateState.String(): privateState,
|
|
publicState.String(): publicState,
|
|
}
|
|
|
|
func (o oidcState) String() string {
|
|
return [...]string{
|
|
"unknown",
|
|
"inactive",
|
|
"active-private",
|
|
"active-public",
|
|
}[o]
|
|
}
|
|
|
|
// createOidcInRepo creates an oidc auth method in a repo and returns the result.
|
|
// This method should never return a nil AuthMethod without returning an error.
|
|
func (s Service) createOidcInRepo(ctx context.Context, scopeId string, item *pb.AuthMethod) (*oidc.AuthMethod, error) {
|
|
u, _, _, err := toStorageOidcAuthMethod(ctx, scopeId, item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
repo, err := s.oidcRepoFn()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out, err := repo.CreateAuthMethod(ctx, u)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to create auth method: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s Service) updateOidcInRepo(ctx context.Context, scopeId string, req *pbs.UpdateAuthMethodRequest) (*oidc.AuthMethod, bool, error) {
|
|
item := req.GetItem()
|
|
u, dryRun, forced, err := toStorageOidcAuthMethod(ctx, scopeId, item)
|
|
if err != nil {
|
|
return nil, dryRun, err
|
|
}
|
|
u.PublicId = req.GetId()
|
|
|
|
var opts []oidc.Option
|
|
if forced {
|
|
opts = append(opts, oidc.WithForce())
|
|
}
|
|
if dryRun {
|
|
opts = append(opts, oidc.WithDryRun())
|
|
}
|
|
|
|
version := item.GetVersion()
|
|
dbMask := oidcMaskManager.Translate(req.GetUpdateMask().GetPaths())
|
|
if len(dbMask) == 0 {
|
|
return nil, dryRun, handlers.InvalidArgumentErrorf("No valid fields included in the update mask.", map[string]string{"update_mask": "No valid fields provided in the update mask."})
|
|
}
|
|
|
|
repo, err := s.oidcRepoFn()
|
|
if err != nil {
|
|
return nil, dryRun, err
|
|
}
|
|
out, rowsUpdated, err := repo.UpdateAuthMethod(ctx, u, version, dbMask, opts...)
|
|
if err != nil {
|
|
return nil, dryRun, fmt.Errorf("unable to update auth method: %w", err)
|
|
}
|
|
if rowsUpdated == 0 && !dryRun && out == nil {
|
|
return nil, dryRun, handlers.NotFoundErrorf("AuthMethod %q doesn't exist or incorrect version provided.", req.GetId())
|
|
}
|
|
return out, dryRun, nil
|
|
}
|
|
|
|
func (s Service) authenticateOidc(ctx context.Context, req *pbs.AuthenticateRequest, authResults *auth.VerifyResults) (*pbs.AuthenticateResponse, error) {
|
|
const op = "authmethod_service.(Service).authenticateOidc"
|
|
if req == nil {
|
|
return nil, errors.New(ctx, errors.InvalidParameter, op, "Nil request.")
|
|
}
|
|
if authResults == nil {
|
|
return nil, errors.New(ctx, errors.InvalidParameter, op, "Nil auth results.")
|
|
}
|
|
switch req.GetCommand() {
|
|
case startCommand:
|
|
return s.authenticateOidcStart(ctx, req)
|
|
case callbackCommand:
|
|
return s.authenticateOidcCallback(ctx, req)
|
|
case tokenCommand:
|
|
return s.authenticateOidcToken(ctx, req, authResults)
|
|
}
|
|
|
|
return &pbs.AuthenticateResponse{Command: req.GetCommand()}, nil
|
|
}
|
|
|
|
func (s Service) authenticateOidcStart(ctx context.Context, req *pbs.AuthenticateRequest) (*pbs.AuthenticateResponse, error) {
|
|
const op = "authmethod_service.(Service).authenticateOidcStart"
|
|
if req == nil {
|
|
return nil, errors.New(ctx, errors.InvalidParameter, op, "Nil request.")
|
|
}
|
|
|
|
var opts []oidc.Option
|
|
attrs := req.GetOidcStartAttributes()
|
|
if attrs.GetCachedRoundtripPayload() != "" {
|
|
opts = append(opts, oidc.WithRoundtripPayload(attrs.GetCachedRoundtripPayload()))
|
|
}
|
|
|
|
authUrl, tokenId, err := oidc.StartAuth(ctx, s.oidcRepoFn, req.GetAuthMethodId(), opts...)
|
|
if err != nil {
|
|
// this event.WriteError(...) may cause a dup error to be emitted...
|
|
// it should be removed if that's the case.
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("error starting the oidc authentication flow"))
|
|
return nil, errors.New(ctx, errors.Internal, op, "Error generating parameters for starting the OIDC flow. See the controller's log for more information.")
|
|
}
|
|
|
|
return &pbs.AuthenticateResponse{
|
|
Command: req.GetCommand(),
|
|
Attrs: &pbs.AuthenticateResponse_OidcAuthMethodAuthenticateStartResponse{
|
|
OidcAuthMethodAuthenticateStartResponse: &pb.OidcAuthMethodAuthenticateStartResponse{
|
|
AuthUrl: authUrl.String(),
|
|
TokenId: tokenId,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// authenticateOidcCallback behaves differently than other service methods.
|
|
// Because of the way it this is called by the end user, it should only return
|
|
// an error if we are unable to lookup the auth method or the request
|
|
// parameters were invalid. All other errors should be returned back through
|
|
// the response as a finalRedirectUrl to an endpoint that can properly show the
|
|
// error details.
|
|
func (s Service) authenticateOidcCallback(ctx context.Context, req *pbs.AuthenticateRequest) (*pbs.AuthenticateResponse, error) {
|
|
const op = "authmethod_service.(Service).authenticateOidcCallback"
|
|
// TODO: Return all errors (including the validate request based errors
|
|
// in the redirect URL once we start looking at the url used for this
|
|
// request instead of requiring the API URL to be set on the auth method.
|
|
if req == nil {
|
|
return nil, errors.New(ctx, errors.InvalidParameter, op, "Nil request.")
|
|
}
|
|
|
|
repo, err := s.oidcRepoFn()
|
|
if err != nil {
|
|
return nil, errors.Wrap(ctx, err, op)
|
|
}
|
|
am, err := repo.LookupAuthMethod(ctx, req.GetAuthMethodId())
|
|
if err != nil {
|
|
return nil, errors.Wrap(ctx, err, op)
|
|
}
|
|
if am == nil {
|
|
return nil, errors.New(ctx, errors.RecordNotFound, op, fmt.Sprintf("Auth method %s not found.", req.GetAuthMethodId()))
|
|
}
|
|
if am.GetApiUrl() == "" {
|
|
return nil, errors.New(ctx, errors.InvalidParameter, op, "Auth method doesn't have API URL defined.")
|
|
}
|
|
|
|
errRedirectBase := fmt.Sprintf(oidc.AuthenticationErrorsEndpoint, am.GetApiUrl())
|
|
errResponse := func(err error) (*pbs.AuthenticateResponse, error) {
|
|
u := make(url.Values)
|
|
pbErr := handlers.ToApiError(err)
|
|
out, err := handlers.JSONMarshaler().Marshal(pbErr)
|
|
if err != nil {
|
|
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("unable to marshal the error for callback"))
|
|
}
|
|
u.Add("error", string(out))
|
|
errRedirect := fmt.Sprintf("%s?%s", errRedirectBase, u.Encode())
|
|
return &pbs.AuthenticateResponse{
|
|
Command: callbackCommand,
|
|
Attrs: &pbs.AuthenticateResponse_OidcAuthMethodAuthenticateCallbackResponse{
|
|
OidcAuthMethodAuthenticateCallbackResponse: &pb.OidcAuthMethodAuthenticateCallbackResponse{
|
|
FinalRedirectUrl: errRedirect,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
attrs := req.GetOidcAuthMethodAuthenticateCallbackRequest()
|
|
|
|
var finalRedirectUrl string
|
|
if attrs.GetError() != "" {
|
|
err := errors.Wrap(ctx, fmt.Errorf("Error: %q, Details: %q", attrs.GetError(), attrs.GetErrorDescription()), op, errors.WithCode(errors.OidcProviderCallbackError))
|
|
return errResponse(err)
|
|
}
|
|
finalRedirectUrl, err = oidc.Callback(
|
|
ctx,
|
|
s.oidcRepoFn,
|
|
oidc.IamRepoFactory(s.iamRepoFn),
|
|
s.atRepoFn,
|
|
am,
|
|
attrs.GetState(),
|
|
attrs.GetCode())
|
|
if err != nil {
|
|
return errResponse(errors.New(ctx, errors.InvalidParameter, op, "Callback validation failed.", errors.WithWrap(err)))
|
|
}
|
|
|
|
return &pbs.AuthenticateResponse{
|
|
Command: req.GetCommand(),
|
|
Attrs: &pbs.AuthenticateResponse_OidcAuthMethodAuthenticateCallbackResponse{
|
|
OidcAuthMethodAuthenticateCallbackResponse: &pb.OidcAuthMethodAuthenticateCallbackResponse{
|
|
FinalRedirectUrl: finalRedirectUrl,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (s Service) authenticateOidcToken(ctx context.Context, req *pbs.AuthenticateRequest, authResults *auth.VerifyResults) (*pbs.AuthenticateResponse, error) {
|
|
const op = "authmethod_service.(Service).authenticateOidcToken"
|
|
if req == nil {
|
|
return nil, errors.New(ctx, errors.InvalidParameter, op, "Nil request.")
|
|
}
|
|
if authResults == nil {
|
|
return nil, errors.New(ctx, errors.InvalidParameter, op, "Nil auth results.")
|
|
}
|
|
if req.GetOidcAuthMethodAuthenticateTokenRequest() == nil {
|
|
return nil, errors.New(ctx, errors.InvalidParameter, op, "Nil request attributes.")
|
|
}
|
|
|
|
attrs := req.GetOidcAuthMethodAuthenticateTokenRequest()
|
|
if attrs.TokenId == "" {
|
|
return nil, errors.New(ctx, errors.InvalidParameter, op, "Empty token ID in request attributes.")
|
|
}
|
|
|
|
token, err := oidc.TokenRequest(ctx, s.kms, s.atRepoFn, req.GetAuthMethodId(), attrs.TokenId)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Match(errors.T(errors.Forbidden), err):
|
|
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("Forbidden."))
|
|
case errors.Match(errors.T(errors.AuthAttemptExpired), err):
|
|
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("Forbidden."))
|
|
default:
|
|
// this event.WriteError(...) may cause a dup error to be emitted...
|
|
// it should be removed if that's the case.
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("error generating parameters for token request"))
|
|
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("Error generating parameters for token request. See the controller's log for more information."))
|
|
}
|
|
}
|
|
if token == nil {
|
|
if err != nil {
|
|
return nil, errors.New(ctx, errors.Internal, op, "Error generating response attributes.", errors.WithWrap(err))
|
|
}
|
|
return &pbs.AuthenticateResponse{
|
|
Command: req.Command,
|
|
Attrs: &pbs.AuthenticateResponse_OidcAuthMethodAuthenticateTokenResponse{
|
|
OidcAuthMethodAuthenticateTokenResponse: &pb.OidcAuthMethodAuthenticateTokenResponse{
|
|
Status: "unknown",
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
responseToken, err := s.ConvertInternalAuthTokenToApiAuthToken(
|
|
ctx,
|
|
token,
|
|
)
|
|
if err != nil {
|
|
return nil, errors.New(ctx, errors.Internal, op, "Error converting response to proper format.", errors.WithWrap(err))
|
|
}
|
|
return s.convertToAuthenticateResponse(ctx, req, authResults, responseToken)
|
|
}
|
|
|
|
func validateAuthenticateOidcRequest(req *pbs.AuthenticateRequest) error {
|
|
badFields := make(map[string]string)
|
|
|
|
switch req.GetCommand() {
|
|
case startCommand:
|
|
if req.GetOidcStartAttributes() != nil {
|
|
attrs := req.GetOidcStartAttributes()
|
|
switch {
|
|
case attrs == nil:
|
|
badFields["attributes"] = "Attributes field not supplied request"
|
|
default:
|
|
// Ensure we pay no attention to cache information provided by the client
|
|
attrs.CachedRoundtripPayload = ""
|
|
|
|
payload := attrs.GetRoundtripPayload()
|
|
if payload == nil {
|
|
break
|
|
}
|
|
m, err := json.Marshal(payload.AsMap())
|
|
if err != nil {
|
|
// We don't know what's in this payload so we swallow the
|
|
// error, as it could be something sensitive.
|
|
badFields[roundtripPayloadAttributesField] = "Unable to marshal given value as JSON."
|
|
} else {
|
|
// Cache for later
|
|
attrs.CachedRoundtripPayload = string(m)
|
|
}
|
|
}
|
|
}
|
|
case callbackCommand:
|
|
attrs := req.GetOidcAuthMethodAuthenticateCallbackRequest()
|
|
switch {
|
|
case attrs == nil:
|
|
badFields["attributes"] = "Attributes field not supplied request"
|
|
return handlers.InvalidArgumentErrorf("This is a required field.", badFields)
|
|
default:
|
|
if attrs.GetCode() == "" && attrs.GetError() == "" {
|
|
badFields[codeField] = "Code field not supplied in callback request."
|
|
}
|
|
|
|
if attrs.GetState() == "" {
|
|
badFields[stateField] = "State field not supplied in callback request."
|
|
}
|
|
}
|
|
|
|
case tokenCommand:
|
|
tokenType := req.GetType()
|
|
if tokenType == "" {
|
|
// Fall back to deprecated field if type is not set
|
|
tokenType = req.GetTokenType()
|
|
}
|
|
tType := strings.ToLower(strings.TrimSpace(tokenType))
|
|
if tType != "" && tType != "token" && tType != "cookie" {
|
|
badFields[tokenTypeField] = `The only accepted types are "token" and "cookie".`
|
|
}
|
|
|
|
default:
|
|
badFields[commandField] = "Invalid command for this auth method type."
|
|
}
|
|
|
|
if len(badFields) > 0 {
|
|
return handlers.InvalidArgumentErrorf("Invalid fields provided in request.", badFields)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func toStorageOidcAuthMethod(ctx context.Context, scopeId string, in *pb.AuthMethod) (out *oidc.AuthMethod, dryRun, forced bool, err error) {
|
|
const op = "authmethod_service.toStorageOidcAuthMethod"
|
|
if in == nil {
|
|
return nil, false, false, errors.New(ctx, errors.InvalidParameter, op, "nil auth method.")
|
|
}
|
|
attrs := in.GetOidcAuthMethodsAttributes()
|
|
clientId := attrs.GetClientId().GetValue()
|
|
clientSecret := oidc.ClientSecret(attrs.GetClientSecret().GetValue())
|
|
|
|
var opts []oidc.Option
|
|
if in.GetName() != nil {
|
|
opts = append(opts, oidc.WithName(in.GetName().GetValue()))
|
|
}
|
|
if in.GetDescription() != nil {
|
|
opts = append(opts, oidc.WithDescription(in.GetDescription().GetValue()))
|
|
}
|
|
|
|
if iss := strings.TrimSpace(attrs.GetIssuer().GetValue()); iss != "" {
|
|
// Strip off everything after and including ".well-known/openid-configuration"
|
|
// but leave the "/" attached to the end.
|
|
iss = strings.SplitN(iss, ".well-known/", 2)[0]
|
|
issuer, err := url.Parse(iss)
|
|
if err != nil {
|
|
return nil, false, false, errors.Wrap(ctx, err, op, errors.WithMsg("cannot parse issuer"), errors.WithCode(errors.InvalidParameter))
|
|
}
|
|
opts = append(opts, oidc.WithIssuer(issuer))
|
|
}
|
|
if apiUrl := strings.TrimSpace(attrs.GetApiUrlPrefix().GetValue()); apiUrl != "" {
|
|
apiU, err := url.Parse(apiUrl)
|
|
if err != nil {
|
|
return nil, false, false, errors.Wrap(ctx, err, op, errors.WithMsg("cannot parse api_url_prefix"), errors.WithCode(errors.InvalidParameter))
|
|
}
|
|
opts = append(opts, oidc.WithApiUrl(apiU))
|
|
}
|
|
|
|
if attrs.GetMaxAge() != nil {
|
|
maxAge := attrs.GetMaxAge().GetValue()
|
|
if maxAge == 0 {
|
|
opts = append(opts, oidc.WithMaxAge(-1))
|
|
} else {
|
|
opts = append(opts, oidc.WithMaxAge(int(maxAge)))
|
|
}
|
|
}
|
|
var signAlgs []oidc.Alg
|
|
for _, a := range attrs.GetSigningAlgorithms() {
|
|
signAlgs = append(signAlgs, oidc.Alg(a))
|
|
}
|
|
if len(signAlgs) > 0 {
|
|
opts = append(opts, oidc.WithSigningAlgs(signAlgs...))
|
|
}
|
|
var prompts []oidc.PromptParam
|
|
for _, a := range attrs.GetPrompts() {
|
|
prompts = append(prompts, oidc.PromptParam(a))
|
|
}
|
|
if len(prompts) > 0 {
|
|
opts = append(opts, oidc.WithPrompts(prompts...))
|
|
}
|
|
if len(attrs.GetAllowedAudiences()) > 0 {
|
|
opts = append(opts, oidc.WithAudClaims(attrs.GetAllowedAudiences()...))
|
|
}
|
|
|
|
if len(attrs.GetIdpCaCerts()) > 0 {
|
|
certs, err := oidc.ParseCertificates(ctx, attrs.GetIdpCaCerts()...)
|
|
if err != nil {
|
|
return nil, false, false, err
|
|
}
|
|
opts = append(opts, oidc.WithCertificates(certs...))
|
|
}
|
|
|
|
if len(attrs.GetClaimsScopes()) > 0 {
|
|
opts = append(opts, oidc.WithClaimsScopes(attrs.GetClaimsScopes()...))
|
|
}
|
|
|
|
if len(attrs.GetAccountClaimMaps()) > 0 {
|
|
claimsMap := make(map[string]oidc.AccountToClaim, len(attrs.GetAccountClaimMaps()))
|
|
for _, v := range attrs.GetAccountClaimMaps() {
|
|
acm, err := oidc.ParseAccountClaimMaps(ctx, v)
|
|
if err != nil {
|
|
return nil, false, false, errors.Wrap(ctx, err, op)
|
|
}
|
|
if len(acm) > 1 {
|
|
return nil, false, false, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("unable to parse account claim map %s", v))
|
|
}
|
|
var m oidc.ClaimMap
|
|
for _, m = range acm {
|
|
}
|
|
to, err := oidc.ConvertToAccountToClaim(ctx, m.To)
|
|
if err != nil {
|
|
return nil, false, false, errors.Wrap(ctx, err, op)
|
|
}
|
|
claimsMap[m.From] = to
|
|
}
|
|
opts = append(opts, oidc.WithAccountClaimMap(claimsMap))
|
|
}
|
|
|
|
u, err := oidc.NewAuthMethod(ctx, scopeId, clientId, clientSecret, opts...)
|
|
if err != nil {
|
|
return nil, false, false, handlers.ApiErrorWithCodeAndMessage(codes.Internal, "Unable to build auth method: %v.", err)
|
|
}
|
|
return u, attrs.GetDryRun(), attrs.GetDisableDiscoveredConfigValidation(), nil
|
|
}
|