You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
boundary/internal/auth/oidc/auth_method.go

458 lines
16 KiB

package oidc
import (
"context"
"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"
"github.com/hashicorp/go-multierror"
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.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 *multierror.Error
if err := am.validate(ctx, op); err != nil {
result = multierror.Append(result, errors.Wrap(ctx, err, op))
}
if am.Issuer == "" {
result = multierror.Append(result, errors.New(ctx, errors.InvalidParameter, op, "missing issuer"))
}
if am.ApiUrl == "" {
result = multierror.Append(result, errors.New(ctx, errors.InvalidParameter, op, "missing api url"))
}
if am.ClientId == "" {
result = multierror.Append(result, errors.New(ctx, errors.InvalidParameter, op, "missing client id"))
}
if am.ClientSecret == "" {
result = multierror.Append(result, errors.New(ctx, errors.InvalidParameter, op, "missing client secret"))
}
if len(am.SigningAlgs) == 0 {
result = multierror.Append(result, errors.New(ctx, errors.InvalidParameter, op, "missing signing algorithms"))
}
return result.ErrorOrNil()
}
type convertedValues struct {
Algs []interface{}
Auds []interface{}
Certs []interface{}
ClaimsScopes []interface{}
AccountClaimMaps []interface{}
}
// 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 []interface{}
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)
}
return &convertedValues{
Algs: addAlgs,
Auds: addAuds,
Certs: addCerts,
ClaimsScopes: addScopes,
AccountClaimMaps: addAccountClaimMaps,
}, 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) ([]interface{}, error) {
const op = "oidc.(AuthMethod).convertSigningAlgs"
if am.PublicId == "" {
return nil, errors.New(ctx, errors.InvalidPublicId, op, "missing public id")
}
newInterfaces := make([]interface{}, 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) ([]interface{}, error) {
const op = "oidc.(AuthMethod).convertAudClaims"
if am.PublicId == "" {
return nil, errors.New(ctx, errors.InvalidPublicId, op, "missing public id")
}
newInterfaces := make([]interface{}, 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) ([]interface{}, error) {
const op = "oidc.(AuthMethod).convertCertificates"
if am.PublicId == "" {
return nil, errors.New(ctx, errors.InvalidPublicId, op, "missing public id")
}
newInterfaces := make([]interface{}, 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) ([]interface{}, error) {
const op = "oidc.(AuthMethod).convertClaimsScopes"
if am.PublicId == "" {
return nil, errors.New(ctx, errors.InvalidPublicId, op, "missing public id")
}
newInterfaces := make([]interface{}, 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) ([]interface{}, error) {
const op = "oidc.(AuthMethod).convertAccountClaimMaps"
if am.PublicId == "" {
return nil, errors.New(ctx, errors.InvalidPublicId, op, "missing public id")
}
newInterfaces := make([]interface{}, 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
}