Files
threshold/internal/auth/oidc/auth_method.go
T
Elim Tsiagbey 8b8d2822df feat(oidc): OIDC Prompt (#4053)
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
2023-11-21 11:30:02 -05:00

491 lines
17 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package oidc
import (
"context"
stderrors "errors"
"fmt"
"net/url"
"sort"
"strings"
"github.com/hashicorp/boundary/internal/auth/oidc/store"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/internal/libs/crypto"
"github.com/hashicorp/boundary/internal/oplog"
wrapping "github.com/hashicorp/go-kms-wrapping/v2"
"github.com/hashicorp/go-kms-wrapping/v2/extras/structwrapping"
kvbuilder "github.com/hashicorp/go-secure-stdlib/kv-builder"
"google.golang.org/protobuf/proto"
)
// defaultAuthMethodTableName defines the default table name for an AuthMethod
const defaultAuthMethodTableName = "auth_oidc_method"
// AuthMethod contains an OIDC auth method configuration. It is owned
// by a scope. AuthMethods can have Accounts, AudClaims,
// CallbackUrls, Certificates, SigningAlgs. AuthMethods also have one State at
// any given time which determines it's behavior for many its operations.
type AuthMethod struct {
*store.AuthMethod
tableName string
}
// NewAuthMethod creates a new in memory AuthMethod assigned to scopeId.
// WithMaxAge, WithName and WithDescription are the only valid options. All
// other options are ignored.
//
// State equals the state of the OIDC auth method. State is not a supported
// parameter when creating new AuthMethod's since it must be Inactive for all
// new AuthMethods.
//
// Issuer equals a URL that identifies the OIDC provider.
// Boundary will strip off anything beyond scheme, host and port
//
// ClientId equals an OAuth 2.0 Client Identifier valid at the Authorization
// Server.
//
// ClientSecret equals the client's secret which will be encrypted when stored
// in the database and an hmac representation will also be stored when ever the
// secret changes. The secret is not returned via the API, the hmac is returned
// so callers can determine if it's been updated.
//
// MaxAge equals the Maximum Authentication Age. Specifies the allowable elapsed
// time in seconds since the last time the End-User was actively authenticated
// by the OP. If the elapsed time is greater than this value, the OP MUST
// attempt to actively re-authenticate the End-User. A value -1 basically
// forces the IdP to re-authenticate the End-User. Zero is not a valid value.
//
// See: https://openid.net/specs/openid-connect-core-1_0.html
//
// Supports the options of WithMaxAge, WithSigningAlgs, WithAudClaims,
// WithApiUrl and WithCertificates and all other options are ignored.
func NewAuthMethod(ctx context.Context, scopeId string, clientId string, clientSecret ClientSecret, opt ...Option) (*AuthMethod, error) {
const op = "oidc.NewAuthMethod"
opts := getOpts(opt...)
var u string
switch {
case opts.withIssuer != nil:
// trim off anything beyond scheme, host and port
u = strings.SplitN(opts.withIssuer.String(), ".well-known/", 2)[0]
}
a := &AuthMethod{
AuthMethod: &store.AuthMethod{
ScopeId: scopeId,
Name: opts.withName,
Description: opts.withDescription,
OperationalState: string(opts.withOperationalState),
Issuer: u,
ClientId: clientId,
ClientSecret: string(clientSecret),
MaxAge: int32(opts.withMaxAge),
ClaimsScopes: opts.withClaimsScopes,
},
}
if opts.withApiUrl != nil {
a.ApiUrl = opts.withApiUrl.String()
}
if len(opts.withAudClaims) > 0 {
a.AudClaims = make([]string, 0, len(opts.withAudClaims))
a.AudClaims = append(a.AudClaims, opts.withAudClaims...)
}
if len(opts.withCertificates) > 0 {
a.Certificates = make([]string, 0, len(opts.withCertificates))
pem, err := EncodeCertificates(ctx, opts.withCertificates...)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
a.Certificates = append(a.Certificates, pem...)
}
if len(opts.withSigningAlgs) > 0 {
a.SigningAlgs = make([]string, 0, len(opts.withSigningAlgs))
for _, alg := range opts.withSigningAlgs {
a.SigningAlgs = append(a.SigningAlgs, string(alg))
}
}
if len(opts.withPrompts) > 0 {
a.Prompts = make([]string, 0, len(opts.withPrompts))
for _, prompts := range opts.withPrompts {
a.Prompts = append(a.Prompts, string(prompts))
}
}
if len(opts.withAccountClaimMap) > 0 {
a.AccountClaimMaps = make([]string, 0, len(opts.withAccountClaimMap))
for k, v := range opts.withAccountClaimMap {
a.AccountClaimMaps = append(a.AccountClaimMaps, fmt.Sprintf("%s=%s", k, v))
}
}
if a.OperationalState != string(InactiveState) {
if err := a.isComplete(ctx); err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("new auth method being created with incomplete data but non-inactive state"))
}
}
if err := a.validate(ctx, op); err != nil {
return nil, err // intentionally not wrapped.
}
if a.ClientSecretHmac != "" {
return nil, errors.New(ctx, errors.InvalidParameter, op, "client secret hmac should be empty")
}
return a, nil
}
// validate the AuthMethod. On success, it will return nil. Since setting up an
// OIDC auth method requires a dance with the IdP, where you're need X before you
// can configure Y, we allow things like the discovery URL, client ID, client
// secret, etc to be empty until the AuthMethod moves into a PublicActive state.
// That means validate can't completely ensure the data is valid and ultimately
// we must rely on the database constraints/triggers to ensure the AuthMethod's
// data integrity.
//
// Also, you can't enforce that MaxAge can't equal zero, since the zero value ==
// NULL in the database and that's what you want if it's unset. A db constraint
// will enforce that MaxAge is either -1, NULL or greater than zero.
func (a *AuthMethod) validate(ctx context.Context, caller errors.Op) error {
if a.ScopeId == "" {
return errors.New(ctx, errors.InvalidParameter, caller, "missing scope id")
}
if !validState(a.OperationalState) {
return errors.New(ctx, errors.InvalidParameter, caller, fmt.Sprintf("invalid state: %s", a.OperationalState))
}
if a.Issuer != "" {
if _, err := url.Parse(a.Issuer); err != nil {
return errors.New(ctx, errors.InvalidParameter, caller, "not a valid issuer", errors.WithWrap(err))
}
}
if a.ApiUrl != "" {
if _, err := url.Parse(a.ApiUrl); err != nil {
return errors.New(ctx, errors.InvalidParameter, caller, "not a valid api url", errors.WithWrap(err))
}
}
if a.MaxAge < -1 {
return errors.New(ctx, errors.InvalidParameter, caller, "max age cannot be less than -1")
}
return nil
}
// AllocAuthMethod makes an empty one in memory
func AllocAuthMethod() AuthMethod {
return AuthMethod{
AuthMethod: &store.AuthMethod{},
}
}
// Clone an AuthMethod.
func (a *AuthMethod) Clone() *AuthMethod {
cp := proto.Clone(a.AuthMethod)
return &AuthMethod{
AuthMethod: cp.(*store.AuthMethod),
}
}
// TableName returns the table name.
func (a *AuthMethod) TableName() string {
if a.tableName != "" {
return a.tableName
}
return defaultAuthMethodTableName
}
// SetTableName sets the table name.
func (a *AuthMethod) SetTableName(n string) {
a.tableName = n
}
// oplog will create oplog metadata for the AuthMethod.
func (a *AuthMethod) oplog(op oplog.OpType) oplog.Metadata {
metadata := oplog.Metadata{
"resource-public-id": []string{a.GetPublicId()},
"resource-type": []string{"oidc auth method"},
"op-type": []string{op.String()},
"scope-id": []string{a.ScopeId},
}
return metadata
}
// encrypt the auth method before writing it to the db
func (a *AuthMethod) encrypt(ctx context.Context, cipher wrapping.Wrapper) error {
const op = "oidc.(AuthMethod).encrypt"
if cipher == nil {
return errors.New(ctx, errors.InvalidParameter, op, "missing cipher")
}
if err := structwrapping.WrapStruct(ctx, cipher, a.AuthMethod, nil); err != nil {
return errors.Wrap(ctx, err, op, errors.WithCode(errors.Encrypt))
}
keyId, err := cipher.KeyId(ctx)
if err != nil {
return errors.Wrap(ctx, err, op, errors.WithCode(errors.Encrypt), errors.WithMsg("failed to read cipher key id"))
}
a.KeyId = keyId
if err := a.hmacClientSecret(ctx, cipher); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
// decrypt the auth method after reading it from the db
func (a *AuthMethod) decrypt(ctx context.Context, cipher wrapping.Wrapper) error {
const op = "oidc.(AuthMethod).decrypt"
if cipher == nil {
return errors.New(ctx, errors.InvalidParameter, op, "missing cipher")
}
if err := structwrapping.UnwrapStruct(ctx, cipher, a.AuthMethod, nil); err != nil {
return errors.Wrap(ctx, err, op, errors.WithCode(errors.Decrypt))
}
return nil
}
// hmacClientSecret before writing it to the db
func (a *AuthMethod) hmacClientSecret(ctx context.Context, cipher wrapping.Wrapper) error {
const op = "oidc.(AuthMethod).hmacClientSecret"
if cipher == nil {
return errors.New(ctx, errors.InvalidParameter, op, "missing cipher")
}
// this operation currently uses the legacy WithEd25519 option for hmac'ing.
// we should likely deprecate this and introduce a new "crypto version" of
// this attribute.
hm, err := crypto.HmacSha256(ctx, []byte(a.ClientSecret), cipher, []byte(a.PublicId), nil, crypto.WithBase64Encoding(), crypto.WithEd25519())
if err != nil {
return errors.Wrap(ctx, err, op, errors.WithCode(errors.Code(errors.Encryption)))
}
a.ClientSecretHmac = hm
return nil
}
// isComplete() checks the auth method to see if it has all the required
// components of a complete/valid oidc auth method.
func (am *AuthMethod) isComplete(ctx context.Context) error {
const op = "oidc.(AuthMethod).isComplete"
var result error
if err := am.validate(ctx, op); err != nil {
result = stderrors.Join(result, errors.Wrap(ctx, err, op))
}
if am.Issuer == "" {
result = stderrors.Join(result, errors.New(ctx, errors.InvalidParameter, op, "missing issuer"))
}
if am.ApiUrl == "" {
result = stderrors.Join(result, errors.New(ctx, errors.InvalidParameter, op, "missing api url"))
}
if am.ClientId == "" {
result = stderrors.Join(result, errors.New(ctx, errors.InvalidParameter, op, "missing client id"))
}
if am.ClientSecret == "" {
result = stderrors.Join(result, errors.New(ctx, errors.InvalidParameter, op, "missing client secret"))
}
if len(am.SigningAlgs) == 0 {
result = stderrors.Join(result, errors.New(ctx, errors.InvalidParameter, op, "missing signing algorithms"))
}
return result
}
type convertedValues struct {
Algs []any
Auds []any
Certs []any
ClaimsScopes []any
AccountClaimMaps []any
Prompts []any
}
// convertValueObjects converts the embedded value objects. It will return an
// error if the AuthMethod's public id is not set.
func (am *AuthMethod) convertValueObjects(ctx context.Context) (*convertedValues, error) {
const op = "oidc.(AuthMethod).valueObjects"
if am.PublicId == "" {
return nil, errors.New(ctx, errors.InvalidPublicId, op, "missing public id")
}
var err error
var addAlgs, addAuds, addCerts, addScopes, addAccountClaimMaps, addPrompts []any
if addAlgs, err = am.convertSigningAlgs(ctx); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
if addAuds, err = am.convertAudClaims(ctx); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
if addCerts, err = am.convertCertificates(ctx); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
if addScopes, err = am.convertClaimsScopes(ctx); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
if addAccountClaimMaps, err = am.convertAccountClaimMaps(ctx); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
if addPrompts, err = am.convertPrompts(ctx); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return &convertedValues{
Algs: addAlgs,
Auds: addAuds,
Certs: addCerts,
ClaimsScopes: addScopes,
AccountClaimMaps: addAccountClaimMaps,
Prompts: addPrompts,
}, nil
}
// convertSigningAlgs converts the embedded signing algorithms from []string
// to []interface{} where each slice element is a *SigningAlg. It will return an
// error if the AuthMethod's public id is not set.
func (am *AuthMethod) convertSigningAlgs(ctx context.Context) ([]any, error) {
const op = "oidc.(AuthMethod).convertSigningAlgs"
if am.PublicId == "" {
return nil, errors.New(ctx, errors.InvalidPublicId, op, "missing public id")
}
newInterfaces := make([]any, 0, len(am.SigningAlgs))
for _, a := range am.SigningAlgs {
obj, err := NewSigningAlg(ctx, am.PublicId, Alg(a))
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
newInterfaces = append(newInterfaces, obj)
}
return newInterfaces, nil
}
// convertAudClaims converts the embedded audience claims from []string
// to []interface{} where each slice element is a *AudClaim. It will return an
// error if the AuthMethod's public id is not set.
func (am *AuthMethod) convertAudClaims(ctx context.Context) ([]any, error) {
const op = "oidc.(AuthMethod).convertAudClaims"
if am.PublicId == "" {
return nil, errors.New(ctx, errors.InvalidPublicId, op, "missing public id")
}
newInterfaces := make([]any, 0, len(am.AudClaims))
for _, a := range am.AudClaims {
obj, err := NewAudClaim(ctx, am.PublicId, a)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
newInterfaces = append(newInterfaces, obj)
}
return newInterfaces, nil
}
// convertCertificates converts the embedded certificates from []string
// to []interface{} where each slice element is a *Certificate. It will return an
// error if the AuthMethod's public id is not set.
func (am *AuthMethod) convertCertificates(ctx context.Context) ([]any, error) {
const op = "oidc.(AuthMethod).convertCertificates"
if am.PublicId == "" {
return nil, errors.New(ctx, errors.InvalidPublicId, op, "missing public id")
}
newInterfaces := make([]any, 0, len(am.Certificates))
for _, cert := range am.Certificates {
obj, err := NewCertificate(ctx, am.PublicId, cert)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
newInterfaces = append(newInterfaces, obj)
}
return newInterfaces, nil
}
// convertClaimsScopes converts the embedded claims scopes from []string
// to []interface{} where each slice element is a *ClaimsScope. It will return an
// error if the AuthMethod's public id is not set.
func (am *AuthMethod) convertClaimsScopes(ctx context.Context) ([]any, error) {
const op = "oidc.(AuthMethod).convertClaimsScopes"
if am.PublicId == "" {
return nil, errors.New(ctx, errors.InvalidPublicId, op, "missing public id")
}
newInterfaces := make([]any, 0, len(am.ClaimsScopes))
for _, cs := range am.ClaimsScopes {
obj, err := NewClaimsScope(ctx, am.PublicId, cs)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
newInterfaces = append(newInterfaces, obj)
}
return newInterfaces, nil
}
// convertAccountClaimMaps converts the embedded account claim maps from
// []string to []interface{} where each slice element is a *AccountClaimMap. It
// will return an error if the AuthMethod's public id is not set or it can
// convert the account claim maps.
func (am *AuthMethod) convertAccountClaimMaps(ctx context.Context) ([]any, error) {
const op = "oidc.(AuthMethod).convertAccountClaimMaps"
if am.PublicId == "" {
return nil, errors.New(ctx, errors.InvalidPublicId, op, "missing public id")
}
newInterfaces := make([]any, 0, len(am.AccountClaimMaps))
const (
from = 0
to = 1
)
acms, err := ParseAccountClaimMaps(ctx, am.AccountClaimMaps...)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
for _, m := range acms {
toClaim, err := ConvertToAccountToClaim(ctx, m.To)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
obj, err := NewAccountClaimMap(ctx, am.PublicId, m.From, toClaim)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
newInterfaces = append(newInterfaces, obj)
}
return newInterfaces, nil
}
// ClaimMap defines the To and From of an oidc claim map
type ClaimMap struct {
To string
From string
}
// ParseAccountClaimMaps will parse the inbound claim maps
func ParseAccountClaimMaps(ctx context.Context, m ...string) ([]ClaimMap, error) {
const op = "oidc.parseAccountClaimMaps"
var b kvbuilder.Builder
if err := b.Add(m...); err != nil {
return nil, errors.New(ctx, errors.InvalidParameter, op, "error parsing map", errors.WithWrap(err))
}
fromKeys := make([]string, 0, len(m))
for k := range b.Map() {
fromKeys = append(fromKeys, k)
}
sort.Strings(fromKeys)
claimMap := make([]ClaimMap, 0, len(fromKeys))
for _, from := range fromKeys {
var ok bool
to, ok := b.Map()[from].(string)
if !ok {
return nil, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("account claim map %s value %q is not a string", from, b.Map()[from]))
}
claimMap = append(claimMap, ClaimMap{
To: to,
From: from,
})
}
return claimMap, nil
}
// convertPrompts converts the embedded prompts from []string
// to []interface{} where each slice element is a *Prompt. It will return an
// error if the AuthMethod's public id is not set.
func (am *AuthMethod) convertPrompts(ctx context.Context) ([]any, error) {
const op = "oidc.(AuthMethod).convertPrompts"
if am.PublicId == "" {
return nil, errors.New(ctx, errors.InvalidPublicId, op, "missing public id")
}
newInterfaces := make([]any, 0, len(am.Prompts))
for _, a := range am.Prompts {
obj, err := NewPrompt(ctx, am.PublicId, PromptParam(a))
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
newInterfaces = append(newInterfaces, obj)
}
return newInterfaces, nil
}