2021-07-29 18:52:56 -06:00
// Copyright (c) HashiCorp, Inc.
2021-04-08 20:54:32 -04:00
// SPDX-License-Identifier: BUSL-1.1
2023-02-02 17:15:17 -08:00
2021-04-08 20:54:32 -04:00
package oidc
import (
"context"
"fmt"
"strings"
"time"
"github.com/hashicorp/boundary/internal/auth/oidc/request"
"github.com/hashicorp/boundary/internal/authtoken"
"github.com/hashicorp/boundary/internal/errors"
2023-10-30 08:54:00 -07:00
"github.com/hashicorp/boundary/internal/event"
2021-04-08 20:54:32 -04:00
"github.com/hashicorp/cap/oidc"
2021-06-01 19:15:57 -05:00
"github.com/hashicorp/go-bexpr"
"github.com/mitchellh/pointerstructure"
2024-02-14 17:14:31 -05:00
"google.golang.org/protobuf/proto"
2021-04-08 20:54:32 -04:00
)
// Callback is an oidc domain service function for processing a successful OIDC
// Authentication Response from an IdP oidc callback. On success, it returns a
// final redirect URL for the response to the IdP.
//
// Callback can return several errors including errors.Forbidden for requests
// with non-unique states (which are replays)
//
// For more info on a successful OIDC Authentication Response see:
// https://openid.net/specs/openid-connect-core-1_0.html#AuthResponse
//
// The service operation includes:
//
// * Decrypt the state which has been encrypted with the OIDC DEK. If decryption
// fails, and error is returned. Decrypted state payload includes the
// token_request_id, nonce and final_redirect_url.
//
// * Exchange the callbackCodeParameter for provider tokens and validate the
// tokens. Call UserInfo endpoint using access token.
//
// * Use oidc.(Repository).upsertAccount to create/update account using ID
// Tokens claims. The "sub" claim as external ID and setting email and full name
// for the account.
//
// * Use iam.(Repository).LookupUserWithLogin(...) look up the iam.User matching
// the Account.
//
// * Use the authtoken.(Repository).CreateAuthToken(...) to create a pending
// auth token for the authenticated user.
func Callback (
ctx context . Context ,
oidcRepoFn OidcRepoFactory ,
iamRepoFn IamRepoFactory ,
atRepoFn AuthTokenRepoFactory ,
2021-04-13 09:21:26 -07:00
am * AuthMethod ,
2022-04-19 11:42:54 -04:00
state , code string ,
) ( finalRedirect string , e error ) {
2021-04-08 20:54:32 -04:00
const op = "oidc.Callback"
if oidcRepoFn == nil {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . InvalidParameter , op , "missing oidc repository function" )
2021-04-08 20:54:32 -04:00
}
if iamRepoFn == nil {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . InvalidParameter , op , "missing iam repository function" )
2021-04-08 20:54:32 -04:00
}
if atRepoFn == nil {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . InvalidParameter , op , "missing auth token repository function" )
2021-04-08 20:54:32 -04:00
}
2021-04-13 09:21:26 -07:00
if am == nil {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . InvalidParameter , op , "missing auth method" )
2021-04-08 20:54:32 -04:00
}
if state == "" {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . InvalidParameter , op , "missing state" )
2021-04-08 20:54:32 -04:00
}
if code == "" {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . InvalidParameter , op , "missing code" )
2021-04-08 20:54:32 -04:00
}
// One comment before we begin, Callback orchestrates repositories which
// execute order dependent database transactions: create an account,
// perhaps create an iam.User, and if everything is successful it will
// create a pending token. Any of these independent transactions could
// fail, but we don't need to undo a previous successful transactions
// in the Callback. The sequentially order transactions all leave the
// database is a consistent state, even if subsequent transactions fail.
// Future requests will not be hampered by previous unsuccessful Callback
// operations.
r , err := oidcRepoFn ()
if err != nil {
2021-07-29 18:52:56 -06:00
return "" , errors . Wrap ( ctx , err , op )
2021-04-08 20:54:32 -04:00
}
// state is a proto request.Wrapper msg, which contains a cipher text field, so
// we need the derived wrapper that was used to encrypt it.
2021-04-13 09:21:26 -07:00
requestWrapper , err := requestWrappingWrapper ( ctx , r . kms , am . ScopeId , am . GetPublicId ())
2021-04-08 20:54:32 -04:00
if err != nil {
2021-07-29 18:52:56 -06:00
return "" , errors . Wrap ( ctx , err , op )
2021-04-08 20:54:32 -04:00
}
2021-05-02 15:25:29 -04:00
stateWrapper , err := UnwrapMessage ( ctx , state )
2021-04-08 20:54:32 -04:00
if err != nil {
2021-07-29 18:52:56 -06:00
return "" , errors . Wrap ( ctx , err , op )
2021-04-08 20:54:32 -04:00
}
2021-04-12 17:17:28 -04:00
// it appears the authentication request was started with one auth method
// and the callback came to a different auth method.
2021-04-13 09:21:26 -07:00
if stateWrapper . AuthMethodId != am . GetPublicId () {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . InvalidParameter , op , fmt . Sprintf ( "%s auth method id does not match request wrapper auth method id: %s" , am . GetPublicId (), stateWrapper ))
2021-04-12 17:17:28 -04:00
}
2021-04-08 20:54:32 -04:00
stateBytes , err := decryptMessage ( ctx , requestWrapper , stateWrapper )
if err != nil {
2021-07-29 18:52:56 -06:00
return "" , errors . Wrap ( ctx , err , op )
2021-04-08 20:54:32 -04:00
}
var reqState request . State
if err := proto . Unmarshal ( stateBytes , & reqState ); err != nil {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . Unknown , op , "unable to unmarshal request state" , errors . WithWrap ( err ))
2021-04-08 20:54:32 -04:00
}
// get the provider from the cache (if possible). FYI: "get" just does the right thing
// about comparing the cache with the auth method from the db.
provider , err := providerCache (). get ( ctx , am )
if err != nil {
2021-07-29 18:52:56 -06:00
return "" , errors . Wrap ( ctx , err , op )
2021-04-08 20:54:32 -04:00
}
// if auth method is inactive, we don't allow inflight requests to finish if the
// auth method's config has changed since the request was kicked off.
hash , err := provider . ConfigHash ()
if err != nil {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . Unknown , op , "unable to get provider config hash" , errors . WithWrap ( err ))
2021-04-08 20:54:32 -04:00
}
if reqState . ProviderConfigHash != hash && am . OperationalState == string ( InactiveState ) {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . AuthMethodInactive , op , "auth method configuration changed during in-flight authentication attempt" )
2021-04-08 20:54:32 -04:00
}
// before proceeding, make sure the request hasn't timed out
if time . Now (). After ( reqState . ExpirationTime . Timestamp . AsTime ()) {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . AuthAttemptExpired , op , "request state has expired" )
2021-04-08 20:54:32 -04:00
}
// now, we need to prep for the token exchange with the oidc provider, which
// means sort of re-creating the orig oidc.Request with enough info to properly
// validate the ID Token
opts := [] oidc . Option {
oidc . WithState ( state ),
oidc . WithNonce ( reqState . Nonce ),
}
switch {
case am . MaxAge == - 1 :
opts = append ( opts , oidc . WithMaxAge ( 0 ))
case am . MaxAge > 0 :
opts = append ( opts , oidc . WithMaxAge ( uint ( am . MaxAge )))
default :
}
if len ( am . AudClaims ) > 0 {
opts = append ( opts , oidc . WithAudiences ( am . AudClaims ... ))
}
if strings . TrimSpace ( am . ApiUrl ) == "" {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . InvalidParameter , op , "empty api URL" )
2021-04-08 20:54:32 -04:00
}
2021-05-02 15:25:29 -04:00
oidcRequest , err := oidc . NewRequest ( AttemptExpiration , fmt . Sprintf ( CallbackEndpoint , am . ApiUrl ), opts ... )
2021-04-08 20:54:32 -04:00
if err != nil {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . Unknown , op , "unable to create oidc request for token exchange" , errors . WithWrap ( err ))
2021-04-08 20:54:32 -04:00
}
tk , err := provider . Exchange ( ctx , oidcRequest , state , code )
if err != nil {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . Unknown , op , "unable to complete exchange with oidc provider" , errors . WithWrap ( err ))
2021-04-08 20:54:32 -04:00
}
// okay, now we need some claims from both the ID Token and userinfo, so we can
// upsert an auth account
2022-10-25 18:29:38 -05:00
idTkClaims := map [ string ] any {} // intentionally, NOT nil for call to upsertAccount(...)
userInfoClaims := map [ string ] any {} // intentionally, NOT nil for call to upsertAccount(...)
2021-04-08 20:54:32 -04:00
if err := tk . IDToken (). Claims ( & idTkClaims ); err != nil {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . Unknown , op , "unable to parse ID Token claims" , errors . WithWrap ( err ))
2021-04-08 20:54:32 -04:00
}
userInfoTokenSource := tk . StaticTokenSource ()
if userInfoTokenSource != nil {
sub , ok := idTkClaims [ "sub" ].( string )
if ! ok {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . Unknown , op , "subject is not present in ID Token, which should not be possible" )
2021-04-08 20:54:32 -04:00
}
if err := provider . UserInfo ( ctx , userInfoTokenSource , sub , & userInfoClaims ); err != nil {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . Unknown , op , "unable to get user info from provider" , errors . WithWrap ( err ))
2021-04-08 20:54:32 -04:00
}
}
acct , err := r . upsertAccount ( ctx , am , idTkClaims , userInfoClaims )
if err != nil {
2021-07-29 18:52:56 -06:00
return "" , errors . Wrap ( ctx , err , op )
2021-04-08 20:54:32 -04:00
}
2021-06-01 19:15:57 -05:00
// Get the set of all managed groups so we can filter
2024-11-14 15:47:08 -08:00
mgs , _ , err := r . ListManagedGroups ( ctx , am . GetPublicId (), WithLimit ( - 1 ))
2021-06-01 19:15:57 -05:00
if err != nil {
2021-07-29 18:52:56 -06:00
return "" , errors . Wrap ( ctx , err , op )
2021-06-01 19:15:57 -05:00
}
if len ( mgs ) > 0 {
matchedMgs := make ([] * ManagedGroup , 0 , len ( mgs ))
2022-10-25 18:29:38 -05:00
evalData := map [ string ] any {
2021-06-01 19:15:57 -05:00
"token" : idTkClaims ,
"userinfo" : userInfoClaims ,
}
// Iterate through and check claims against filters
for _ , mg := range mgs {
eval , err := bexpr . CreateEvaluator ( mg . Filter )
if err != nil {
// We check all filters on ingress so this should never happen,
// but we validate anyways
2021-07-29 18:52:56 -06:00
return "" , errors . Wrap ( ctx , err , op )
2021-06-01 19:15:57 -05:00
}
match , err := eval . Evaluate ( evalData )
if err != nil && ! errors . Is ( err , pointerstructure . ErrNotFound ) {
2021-07-29 18:52:56 -06:00
return "" , errors . Wrap ( ctx , err , op )
2021-06-01 19:15:57 -05:00
}
if match {
matchedMgs = append ( matchedMgs , mg )
}
}
// We always pass it in, even if none match, because in that case we
// need to remove any mappings that exist
if _ , _ , err := r . SetManagedGroupMemberships ( ctx , am , acct , matchedMgs ); err != nil {
2021-07-29 18:52:56 -06:00
return "" , errors . Wrap ( ctx , err , op )
2021-06-01 19:15:57 -05:00
}
}
2021-04-08 20:54:32 -04:00
// before searching for the iam.User associated with the account,
// we need to see if this particular auth method is allowed to
// autovivify users for the scope.
iamRepo , err := iamRepoFn ()
if err != nil {
2021-07-29 18:52:56 -06:00
return "" , errors . Wrap ( ctx , err , op )
2021-04-08 20:54:32 -04:00
}
2022-11-14 11:40:43 -08:00
_ , err = iamRepo . LookupScope ( ctx , am . ScopeId )
2021-04-08 20:54:32 -04:00
if err != nil {
2022-11-14 11:40:43 -08:00
return "" , errors . Wrap ( ctx , err , op , errors . WithMsg ( "unable to lookup account scope: " + am . ScopeId ))
2021-04-08 20:54:32 -04:00
}
user , err := iamRepo . LookupUserWithLogin ( ctx , acct . PublicId )
if err != nil {
2021-07-29 18:52:56 -06:00
return "" , errors . Wrap ( ctx , err , op )
2021-04-08 20:54:32 -04:00
}
2021-06-01 14:18:10 -05:00
// Now we need to check filters and assign managed groups by filter.
2021-04-08 20:54:32 -04:00
// wow, we're getting close. we just need to create a pending token for this
// successful authentication process, so it can be retrieved by the polling client
// that initialed the authentication attempt.
tokenRepo , err := atRepoFn ()
if err != nil {
2021-07-29 18:52:56 -06:00
return "" , errors . Wrap ( ctx , err , op )
2021-04-08 20:54:32 -04:00
}
2023-10-30 08:54:00 -07:00
authToken , err := tokenRepo . CreateAuthToken ( ctx , user , acct . PublicId , authtoken . WithPublicId ( reqState . TokenRequestId ), authtoken . WithStatus ( authtoken . PendingStatus ))
if err != nil {
2021-04-08 20:54:32 -04:00
if errors . Match ( errors . T ( errors . NotUnique ), err ) {
2021-07-29 18:52:56 -06:00
return "" , errors . New ( ctx , errors . Forbidden , op , "not a unique request" , errors . WithWrap ( err ))
2021-04-08 20:54:32 -04:00
}
2021-07-29 18:52:56 -06:00
return "" , errors . Wrap ( ctx , err , op )
2021-04-08 20:54:32 -04:00
}
2023-10-30 08:54:00 -07:00
if err := event . WriteObservation ( ctx , op , event . WithDetails ( "user_id" , user . GetPublicId (), "auth_token_start" ,
authToken . GetCreateTime (), "auth_token_end" , authToken . GetExpirationTime ())); err != nil {
return "" , errors . Wrap ( ctx , err , op , errors . WithMsg ( "Unable to write observation event for authenticate method" ))
}
2021-04-08 20:54:32 -04:00
// tada! we can return a final redirect URL for the successful authentication.
return reqState . FinalRedirectUrl , nil
}