3
0

Add id_token in auth token request

ID token carries personal information about end-users that authenticate on an OpenID Connect flow.
This commit is contained in:
Denis Arh
2022-01-19 17:20:21 +01:00
parent 46675080fe
commit b61fb170bf
12 changed files with 225 additions and 151 deletions

View File

@@ -12,7 +12,6 @@ import (
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
@@ -25,6 +24,7 @@ import (
"github.com/cortezaproject/corteza-server/auth/settings"
"github.com/cortezaproject/corteza-server/pkg/actionlog"
"github.com/cortezaproject/corteza-server/pkg/auth"
"github.com/cortezaproject/corteza-server/pkg/handle"
"github.com/cortezaproject/corteza-server/pkg/locale"
"github.com/cortezaproject/corteza-server/pkg/options"
"github.com/cortezaproject/corteza-server/pkg/version"
@@ -33,6 +33,7 @@ import (
"github.com/cortezaproject/corteza-server/system/types"
"github.com/go-chi/chi/v5"
oauth2def "github.com/go-oauth2/oauth2/v4"
"github.com/spf13/cast"
"go.uber.org/zap"
"golang.org/x/text/language"
)
@@ -87,17 +88,10 @@ func New(ctx context.Context, log *zap.Logger, oa2m oauth2def.Manager, s store.S
// this is a bit silly and a bad design of the oauth2 server lib
// why do we need to keep on load the client??
var (
clientID uint64
client *types.AuthClient
client *types.AuthClient
)
clientID, err = strconv.ParseUint(id, 10, 64)
if err != nil {
return false, fmt.Errorf("could not authorize client: %w", err)
}
client, err = store.LookupAuthClientByID(ctx, s, clientID)
if err != nil {
if client, err = clientLookup(ctx, s, id); err != nil {
return false, fmt.Errorf("could not authorize client: %w", err)
}
@@ -113,17 +107,10 @@ func New(ctx context.Context, log *zap.Logger, oa2m oauth2def.Manager, s store.S
// this is a bit silly and a bad design of the oauth2 server lib
// why do we need to keep on load the client??
var (
clientID uint64
client *types.AuthClient
client *types.AuthClient
)
clientID, err = strconv.ParseUint(tgr.ClientID, 10, 64)
if err != nil {
return false, fmt.Errorf("could not authorize client: %w", err)
}
client, err = store.LookupAuthClientByID(ctx, s, clientID)
if err != nil {
if client, err = clientLookup(ctx, s, tgr.ClientID); err != nil {
return false, fmt.Errorf("could not authorize client: %w", err)
}
@@ -139,7 +126,7 @@ func New(ctx context.Context, log *zap.Logger, oa2m oauth2def.Manager, s store.S
fieldsValue = make(map[string]interface{})
handlers.SubSplit(ti, fieldsValue)
fieldsValue["refresh_token_expires_in"] = int(ti.GetRefreshExpiresIn() / time.Second)
if err = handlers.Profile(ctx, ti, fieldsValue); err != nil {
if err = userProfile(ctx, s, ti, fieldsValue); err != nil {
log.Error("failed to add profile data", zap.Error(err))
}
@@ -464,3 +451,43 @@ func (svc service) WellKnownOpenIDConfiguration() http.HandlerFunc {
w.Header().Set("Content-Type", "application/json")
}
}
func clientLookup(ctx context.Context, s store.AuthClients, identifier interface{}) (*types.AuthClient, error) {
if id := cast.ToUint64(identifier); id > 0 {
return store.LookupAuthClientByID(ctx, s, id)
} else if h := cast.ToString(identifier); handle.IsValid(h) {
return store.LookupAuthClientByHandle(ctx, s, h)
} else {
return nil, systemService.AuthClientErrInvalidID()
}
}
// Profile fills map with user's data
//
// If scope supports it (contains "profile") user is loaded and
// map is filled with username (handle), email and name
func userProfile(ctx context.Context, s store.Users, ti oauth2def.TokenInfo, data map[string]interface{}) error {
if !auth.CheckScope(ti.GetScope(), "profile") {
return nil
}
userID, _ := auth.ExtractFromSubClaim(ti.GetUserID())
if userID == 0 {
return fmt.Errorf("invalid user ID in 'sub' claim")
}
user, err := store.LookupUserByID(ctx, s, userID)
if err != nil {
return err
}
data["handle"] = user.Handle
data["name"] = user.Name
data["email"] = user.Email
if user.Meta != nil && user.Meta.PreferredLanguage != "" {
data["preferred_language"] = user.Meta.PreferredLanguage
}
return nil
}

View File

@@ -2,6 +2,7 @@ package auth
import (
"context"
"github.com/cortezaproject/corteza-server/store"
"github.com/cortezaproject/corteza-server/system/types"
)
@@ -15,8 +16,12 @@ type (
}
)
func (svc clientService) LookupByID(ctx context.Context, clientID uint64) (*types.AuthClient, error) {
return store.LookupAuthClientByID(ctx, svc.store, clientID)
func (svc clientService) Lookup(ctx context.Context, identifier interface{}) (*types.AuthClient, error) {
return clientLookup(ctx, svc.store, identifier)
}
func (svc clientService) LookupByHandle(ctx context.Context, handle string) (*types.AuthClient, error) {
return store.LookupAuthClientByHandle(ctx, svc.store, handle)
}
func (svc clientService) Confirmed(ctx context.Context, userID uint64) (types.AuthConfirmedClientSet, error) {

View File

@@ -52,12 +52,12 @@ func SetupGothProviders(log *zap.Logger, redirectUrl string, ep ...settings.Prov
wellKnown := strings.TrimSuffix(pc.IssuerUrl, "/") + WellKnown
scope := pc.Scope
scope := strings.Split(pc.Scope, " ")
if len(scope) == 0 {
scope = "email"
scope = append(scope, "email")
}
if provider, err = openidConnect.New(pc.Key, pc.Secret, redirect, wellKnown, scope); err != nil {
if provider, err = openidConnect.New(pc.Key, pc.Secret, redirect, wellKnown, scope...); err != nil {
log.Error("failed to discover OIDC provider", zap.Error(err), zap.String("well-known", wellKnown))
continue
} else {

View File

@@ -83,7 +83,7 @@ func (h *AuthHandlers) getAuthorizedClients(req *request.AuthReq) (ss authClient
ss = make(authClients, 0, len(set))
for i := range set {
client, err = h.ClientService.LookupByID(req.Context(), set[i].ClientID)
client, err = h.ClientService.Lookup(req.Context(), set[i].ClientID)
if errors.IsNotFound(err) {
continue
}

View File

@@ -78,9 +78,7 @@ func (h AuthHandlers) handleSuccessfulExternalAuth(w http.ResponseWriter, r *htt
})(w, r)
}
func (h AuthHandlers) handleFailedExternalAuth(w http.ResponseWriter, r *http.Request, err error) {
//provider := chi.URLParam(r, "provider")
func (h AuthHandlers) handleFailedExternalAuth(w http.ResponseWriter, _ *http.Request, err error) {
if strings.Contains(err.Error(), "Error processing your OAuth request: Invalid oauth_verifier parameter") {
// Just take user through the same loop again
w.Header().Set("Location", GetLinks().Profile)
@@ -88,8 +86,8 @@ func (h AuthHandlers) handleFailedExternalAuth(w http.ResponseWriter, r *http.Re
return
}
fmt.Fprintf(w, "SSO Error: %v", err.Error())
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(w, "SSO Error: %v", err.Error())
}
func beginUserAuth(w http.ResponseWriter, r *http.Request, eh external.ExternalAuthHandler) {

View File

@@ -11,9 +11,11 @@ import (
"strconv"
"strings"
"github.com/lestrrat-go/jwx/jwa"
"github.com/lestrrat-go/jwx/jwk"
"github.com/go-chi/jwtauth"
oauth2errors "github.com/go-oauth2/oauth2/v4/errors"
"github.com/lestrrat-go/jwx/jwk"
"github.com/lestrrat-go/jwx/jwt"
"github.com/cortezaproject/corteza-server/auth/oauth2"
@@ -58,6 +60,9 @@ func (h AuthHandlers) oauth2Authorize(req *request.AuthReq) (err error) {
// No client validation is done at this point;
// first, see if user is able to authenticate.
request.SetOauth2Client(req.Session, client)
// ensure we're dealing with client ID in case someone used handle
req.Request.Form.Set("client_id", strconv.FormatUint(client.ID, 10))
}
// set to -1 to make sure wrapping request handler
@@ -161,7 +166,6 @@ func (h AuthHandlers) oauth2Token(req *request.AuthReq) (err error) {
req.Status = -1
client, err := h.loadRequestedClient(req)
if err != nil {
return h.tokenError(req.Response, err)
}
@@ -318,9 +322,8 @@ func (h AuthHandlers) verifyDefaultClient() error {
func (h AuthHandlers) loadRequestedClient(req *request.AuthReq) (client *types.AuthClient, err error) {
return client, func() (err error) {
var (
id string
clientID uint64
found bool
id string
found bool
)
if id, _, found = req.Request.BasicAuth(); !found {
@@ -331,31 +334,7 @@ func (h AuthHandlers) loadRequestedClient(req *request.AuthReq) (client *types.A
}
}
h.Log.Debug("loading client", zap.String("info", id))
if clientID, err = strconv.ParseUint(id, 10, 64); err != nil {
return errors.InvalidData("failed to parse client ID from params: %v", err)
} else if clientID == 0 {
return errors.InvalidData("invalid client ID")
}
if client = request.GetOauth2Client(req.Session); client != nil {
h.Log.Debug("client loaded from session", zap.Uint64("ID", client.ID))
// ensure that session holds the right client and
// not some leftover from a previous flow
if client.ID != clientID {
h.Log.Debug("stale client found in session")
// cleanup leftovers
client = nil
} else {
return
}
}
client, err = h.ClientService.LookupByID(req.Context(), clientID)
client, err = h.ClientService.Lookup(req.Context(), id)
if err != nil {
return fmt.Errorf("invalid client: %w", err)
}
@@ -370,6 +349,8 @@ func (h AuthHandlers) handleTokenRequest(req *request.AuthReq, client *types.Aut
r = req.Request
w = req.Response
ctx = req.Context()
user *types.User
)
req.Status = -1
@@ -378,7 +359,7 @@ func (h AuthHandlers) handleTokenRequest(req *request.AuthReq, client *types.Aut
return h.tokenError(w, fmt.Errorf("invalid client: %w", err))
}
// add client to context so we can reach it from client store via context.Value() fn
// add client to context: we can reach it from client store via context.Value() fn
// this way we work around the limitations we have with the oauth2 lib.
ctx = context.WithValue(ctx, &oauth2.ContextClientStore{}, client)
r = req.Request.Clone(ctx)
@@ -388,39 +369,71 @@ func (h AuthHandlers) handleTokenRequest(req *request.AuthReq, client *types.Aut
return h.tokenError(w, err)
}
if gt == oauth2def.ClientCredentials {
// Authenticated with client credentials!
//
// We'll use info from client security
if client.Security == nil || client.Security.ImpersonateUser == 0 {
return h.tokenError(w, errors.Internal("auth client security configuration invalid"))
}
tgr.UserID = strings.Join(append(
[]string{fmt.Sprintf("%d", client.Security.ImpersonateUser)},
client.Security.ForcedRoles...,
), " ")
}
ti, err := h.OAuth2.GetAccessToken(ctx, gt, tgr)
if err != nil {
return h.tokenError(w, err)
}
suCtx := auth.SetIdentityToContext(ctx, auth.ServiceUser())
switch gt {
case oauth2def.ClientCredentials:
// Authenticated with client credentials!
// First, validate client's security settings
if client.Security == nil || client.Security.ImpersonateUser == 0 {
return h.tokenError(w, errors.Internal("auth client security configuration invalid"))
}
// Load the user
if user, err = h.UserService.FindByAny(suCtx, client.Security.ImpersonateUser); err != nil {
return h.tokenError(w, fmt.Errorf("could not generate token for impersonated user: %v", err))
}
case oauth2def.AuthorizationCode, oauth2def.Refreshing:
userID := ti.GetUserID()
if i := strings.Index(ti.GetUserID(), " "); i > 0 {
// userID field from the token could contain encoded roles
// @todo investigate if role-encoding into user-id field is still needed?
userID = userID[:i]
}
if user, err = h.UserService.FindByAny(suCtx, userID); err != nil {
return h.tokenError(w, fmt.Errorf("could not generate token: %v", err))
}
default:
return fmt.Errorf("unsupported oauth2 grant type: %v", gt)
}
var (
user = req.AuthUser.User.Clone()
signed []byte
scope = strings.Split(ti.GetScope(), " ")
)
signed, err = auth.JWT().Sign(ti.GetAccess(), user, client.ID, strings.Split(ti.GetScope(), " ")...)
signed, err = auth.JWT().Sign(ti.GetAccess(), user, client.ID, scope...)
if err != nil {
return h.tokenError(w, err)
}
ti.SetAccess(string(signed))
return token(w, h.OAuth2.GetTokenData(ti), nil)
response := h.OAuth2.GetTokenData(ti)
if strings.Contains(client.Scope, "openid") {
var idToken []byte
if idToken, err = generateIdToken(user, client, ti, h.Opt.BaseURL); err != nil {
return h.tokenError(w, err)
}
response["id_token"] = string(idToken)
}
return writeResponse(w, response, nil)
}
func (h AuthHandlers) tokenError(w http.ResponseWriter, err error) error {
data, statusCode, header := h.OAuth2.GetErrorData(err)
return token(w, data, header, statusCode)
return writeResponse(w, data, header, statusCode)
}
func (h AuthHandlers) oauth2PublicKeys(w http.ResponseWriter, r *http.Request) {
@@ -468,43 +481,38 @@ func SubSplit(ti oauth2def.TokenInfo, data map[string]interface{}) {
}
}
// Profile fills map with user's data
//
// If scope supports it (contains "profile") user is loaded and
// map is filled with username (handle), email and name
func Profile(ctx context.Context, ti oauth2def.TokenInfo, data map[string]interface{}) error {
if !auth.CheckScope(ti.GetScope(), "profile") {
return nil
// Generates ID token that is part of OIDC flow for doing corteza-to-corteza auth
func generateIdToken(user *types.User, client *types.AuthClient, ti oauth2def.TokenInfo, baseURL string) (_ []byte, err error) {
token := jwt.New()
if err = token.Set(jwt.IssuerKey, baseURL); err != nil {
return
}
userID, roles := auth.ExtractFromSubClaim(ti.GetUserID())
if userID == 0 {
return fmt.Errorf("invalid user ID in 'sub' claim")
// we do not know what the admin used for client key value
// on the receiving end, so we'll encode both,
// client's ID, and it's handle
aud := []string{strconv.FormatUint(client.ID, 10)}
if len(client.Handle) > 0 {
aud = append(aud, client.Handle)
}
user, err := systemService.DefaultUser.FindByID(
// inject ad-hoc identity into context so that user service is aware who is
// doing the lookup
auth.SetIdentityToContext(ctx, auth.Authenticated(userID, roles...)),
userID,
)
if err != nil {
return err
if err = token.Set("aud", aud); err != nil {
return
}
if err = token.Set("user_id", strconv.FormatUint(user.ID, 10)); err != nil {
return
}
if err = token.Set("email", user.Email); err != nil {
return
}
if err = token.Set(jwt.ExpirationKey, now().Add(ti.GetAccessExpiresIn()).Unix()); err != nil {
return
}
data["handle"] = user.Handle
data["name"] = user.Name
data["email"] = user.Email
if user.Meta != nil && user.Meta.PreferredLanguage != "" {
data["preferred_language"] = user.Meta.PreferredLanguage
}
return nil
return jwt.Sign(token, jwa.HS512, []byte(client.Secret))
}
func token(w http.ResponseWriter, data map[string]interface{}, header http.Header, statusCode ...int) error {
func writeResponse(w http.ResponseWriter, data map[string]interface{}, header http.Header, statusCode ...int) error {
w.Header().Set("Content-Type", "application/json;charset=UTF-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")

View File

@@ -8,6 +8,8 @@ import (
"github.com/cortezaproject/corteza-server/auth/request"
"github.com/cortezaproject/corteza-server/auth/settings"
"github.com/cortezaproject/corteza-server/system/types"
oauth2models "github.com/go-oauth2/oauth2/v4/models"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
@@ -125,3 +127,21 @@ func Test_oauth2AuthorizeSuccessSetParams(t *testing.T) {
rq.Equal(-1, authReq.Status)
rq.Equal(nil, authReq.Data["error"])
}
func Test_generateIdToken(t *testing.T) {
var (
req = require.New(t)
)
signed, err := generateIdToken(
&types.User{},
&types.AuthClient{
Secret: "correct horse battery stable",
},
&oauth2models.Token{},
"http://cortezaproject.org",
)
req.NoError(err)
req.NotEmpty(signed)
}

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"strings"
"time"
"github.com/cortezaproject/corteza-server/auth/external"
"github.com/cortezaproject/corteza-server/auth/request"
@@ -51,11 +52,12 @@ type (
}
userService interface {
FindByAny(ctx context.Context, identifier interface{}) (*types.User, error)
Update(context.Context, *types.User) (*types.User, error)
}
clientService interface {
LookupByID(context.Context, uint64) (*types.AuthClient, error)
Lookup(context.Context, interface{}) (*types.AuthClient, error)
Confirmed(context.Context, uint64) (types.AuthConfirmedClientSet, error)
Revoke(ctx context.Context, userID, clientID uint64) error
}
@@ -136,6 +138,14 @@ const (
TmplInternalError = "error-internal.html.tpl"
)
var (
// wrapper around time.Now() that will aid service testing
now = func() *time.Time {
c := time.Now()
return &c
}
)
func init() {
gob.Register(&types.User{})
gob.Register(&types.AuthClient{})

View File

@@ -19,8 +19,7 @@ var _ oauth2.ClientStore = &ContextClientStore{}
//
// This requires that client is put in context before oauth2 procedures are executed!
func (s ContextClientStore) GetByID(ctx context.Context, id string) (oauth2.ClientInfo, error) {
return &clientInfo{&types.AuthClient{}}, nil
//return &clientInfo{ctx.Value(&ContextClientStore{}).(*types.AuthClient)}, nil
return &clientInfo{ctx.Value(&ContextClientStore{}).(*types.AuthClient)}, nil
}
type (

View File

@@ -65,36 +65,6 @@ func (c CortezaTokenStore) Create(ctx context.Context, info oauth2.TokenInfo) (e
return
}
// this is oauth2 specific go-code and there is no
// need for it to be moved to MakeAuthStructs fn in auth pkg
if code := info.GetCode(); code != "" {
oa2t.Code = code
} else {
// When creating non-access-code tokens,
// we need to overwrite expiration time
// with custom values for access or refresh token
oa2t.Access = info.GetAccess()
oa2t.ExpiresAt = info.GetAccessCreateAt().Add(info.GetAccessExpiresIn())
if refresh := info.GetRefresh(); refresh != "" {
oa2t.Refresh = info.GetRefresh()
oa2t.ExpiresAt = info.GetRefreshCreateAt().Add(info.GetRefreshExpiresIn())
}
}
//if oa2t.ClientID, err = strconv.ParseUint(info.GetClientID(), 10, 64); err != nil {
// return fmt.Errorf("could not parse client ID from token info: %w", err)
//}
//if info.GetUserID() != "" {
// if oa2t.UserID, _ = auth.ExtractFromSubClaim(info.GetUserID()); oa2t.UserID == 0 {
// // UserID stores collection of IDs: user's ID and set of all roles user is member of
// return fmt.Errorf("could not parse user ID from token info")
// }
//}
//
//// copy user id to auth client confirmation
//acc.UserID = oa2t.UserID
if err = store.UpsertAuthConfirmedClient(ctx, c.Store, acc); err != nil {
return
}
@@ -169,7 +139,7 @@ func (c CortezaTokenStore) GetByRefresh(ctx context.Context, refresh string) (oa
return internal, t.Data.Unmarshal(internal)
}
func makeAuthStructs(ctx context.Context, jwtID, userID, clientID uint64, data oauth2.TokenInfo, expiresAt time.Duration) (oa2t *types.AuthOa2token, acc *types.AuthConfirmedClient, err error) {
func makeAuthStructs(ctx context.Context, jwtID, userID, clientID uint64, info oauth2.TokenInfo, expiresAt time.Duration) (oa2t *types.AuthOa2token, acc *types.AuthConfirmedClient, err error) {
var (
eti = auth.GetExtraReqInfoFromContext(ctx)
createdAt = time.Now().Round(time.Second)
@@ -191,9 +161,24 @@ func makeAuthStructs(ctx context.Context, jwtID, userID, clientID uint64, data o
ConfirmedAt: createdAt,
}
if oa2t.Data, err = json.Marshal(data); err != nil {
if oa2t.Data, err = json.Marshal(info); err != nil {
return nil, nil, err
}
if code := info.GetCode(); code != "" {
oa2t.Code = code
} else {
// When creating non-access-code tokens,
// we need to overwrite expiration time
// with custom values for access or refresh token
oa2t.Access = info.GetAccess()
oa2t.ExpiresAt = info.GetAccessCreateAt().Add(info.GetAccessExpiresIn())
if refresh := info.GetRefresh(); refresh != "" {
oa2t.Refresh = info.GetRefresh()
oa2t.ExpiresAt = info.GetRefreshCreateAt().Add(info.GetRefreshExpiresIn())
}
}
return
}

View File

@@ -1,10 +1,14 @@
package oauth2
import (
"fmt"
"net/http"
"strings"
"github.com/cortezaproject/corteza-server/pkg/handle"
"github.com/cortezaproject/corteza-server/pkg/logger"
"github.com/cortezaproject/corteza-server/pkg/options"
"github.com/cortezaproject/corteza-server/pkg/payload"
"github.com/go-oauth2/oauth2/v4"
"github.com/go-oauth2/oauth2/v4/errors"
"github.com/go-oauth2/oauth2/v4/manage"
@@ -73,8 +77,6 @@ func NewServer(manager oauth2.Manager) *server.Server {
AllowedGrantTypes: []oauth2.GrantType{
oauth2.AuthorizationCode,
oauth2.Refreshing,
// before enabling ClientCredentials grant type, we need to know how to modify released token
// using client's security info; how to enforce impersonated user and his roles.
oauth2.ClientCredentials,
},
AllowedCodeChallengeMethods: []oauth2.CodeChallengeMethod{
@@ -83,6 +85,32 @@ func NewServer(manager oauth2.Manager) *server.Server {
},
}, manager)
srv.ClientInfoHandler = func(r *http.Request) (clientID, clientSecret string, err error) {
// check in basic handler first
clientID, clientSecret, err = server.ClientBasicHandler(r)
if clientID == "" && clientSecret == "" {
//error or no error, if ID & secret are empty,
// check the form handler
clientID, clientSecret, err = server.ClientFormHandler(r)
}
// just in case, when client's handle is used instead of the ID
// preload it here
if id := payload.ParseUint64(clientID); id == 0 && handle.IsValid(clientID) {
var client oauth2.ClientInfo
client, err = manager.GetClient(r.Context(), clientID)
if err != nil {
err = fmt.Errorf("could not resolve client info: %v", err)
return
}
clientID = client.GetID()
}
return
}
srv.SetInternalErrorHandler(func(err error) (re *errors.Response) {
return errors.NewResponse(err, 500)
})

View File

@@ -199,12 +199,6 @@ func (svc user) FindByHandle(ctx context.Context, handle string) (u *types.User,
}
// FindByAny finds user by given identifier (context, id, handle, email)
//
// This function goes against the context anti (!!!) pattern we're using
// (and trying to get rid of)
//
// Main reason to push ctx here as the 1st arg is allow (simple) interface definition
// in the consumers that reside under the pkg/
func (svc user) FindByAny(ctx context.Context, identifier interface{}) (u *types.User, err error) {
if ctx, ok := identifier.(context.Context); ok {
identifier = internalAuth.GetIdentityFromContext(ctx).Identity()