Files
upstream-corteza/server/auth/handlers/handle_oauth2.go
upstream-mirror 7e385fc126
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
2026-09-18 01:22:23 +05:00

709 lines
19 KiB
Go

package handlers
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/cortezaproject/corteza/server/pkg/locale"
"golang.org/x/text/language"
"github.com/go-chi/jwtauth"
oauth2errors "github.com/go-oauth2/oauth2/v4/errors"
"github.com/lestrrat-go/jwx/jwa"
"github.com/lestrrat-go/jwx/jwk"
"github.com/lestrrat-go/jwx/jwt"
"github.com/spf13/cast"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/pkg/auth"
"github.com/cortezaproject/corteza/server/pkg/errors"
"github.com/cortezaproject/corteza/server/pkg/logger"
"github.com/cortezaproject/corteza/server/pkg/payload"
systemService "github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
oauth2def "github.com/go-oauth2/oauth2/v4"
"go.uber.org/zap"
)
// oauth2 flow authorize step
//
// OA2 server internals first run user check (see SetUserAuthorizationHandler lambda)
// to ensure user is authenticated;
func (h *AuthHandlers) oauth2Authorize(req *request.AuthReq) (err error) {
if form := request.GetOAuth2AuthParams(req.Session); form != nil {
req.Request.Form = form
h.Log.Debug("restarting oauth2 authorization flow", zap.Any("params", req.Request.Form))
} else {
h.Log.Debug("starting new oauth2 authorization flow", zap.Any("params", req.Request.Form))
}
request.SetOauth2AuthParams(req.Session, nil)
var (
client *types.AuthClient
)
if client, err = h.loadRequestedClient(req); err != nil {
return err
}
if client != nil {
// 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
// does not send status code!
req.Status = -1
err = h.OAuth2.HandleAuthorizeRequest(req.Response, req.Request)
if err != nil {
req.Status = http.StatusInternalServerError
req.Template = TmplInternalError
req.Data["error"] = err
}
return nil
}
func (h *AuthHandlers) oauth2AuthorizeClient(req *request.AuthReq) (err error) {
var (
client = request.GetOauth2Client(req.Session)
)
if client == nil {
return fmt.Errorf("flow broken; client missing")
}
if err = client.Verify(); err != nil {
return
}
if !h.canAuthorizeClient(req.Context(), req.Client) {
h.Log.Error("user's roles do not allow authorization of this client", logger.Uint64("ID", req.Client.ID), zap.String("handle", req.Client.Handle))
request.SetOauth2Client(req.Session, nil)
request.SetOauth2AuthParams(req.Session, nil)
req.RedirectTo = GetLinks().Profile
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "danger",
Text: t("oauth2-authorize-client.alerts.denied", "client", req.Client.Meta.Name),
})
return nil
}
if !req.AuthUser.User.EmailConfirmed {
req.Data["invalidUser"] = true
req.Data["disabled"] = true
} else if client.Trusted {
h.Log.Debug("pre-authorized trusted oauth2 client")
// Client is trusted, no need to show this screen
// move forward and authorize oauth2 request
request.SetOauth2ClientAuthorized(req.Session, true)
req.RedirectTo = GetLinks().OAuth2Authorize
return nil
}
h.Log.Debug("showing oauth2 client auth form")
req.Template = TmplOAuth2AuthorizeClient
return nil
}
func (h *AuthHandlers) oauth2AuthorizeClientProc(req *request.AuthReq) (err error) {
// permissions are already check ed in the oauth2AuthorizeClient fn,
// just making sure
if h.canAuthorizeClient(req.Context(), req.Client) {
request.SetOauth2Client(req.Session, nil)
if _, allow := req.Request.Form["allow"]; allow {
request.SetOauth2ClientAuthorized(req.Session, true)
req.RedirectTo = GetLinks().OAuth2Authorize
return
}
}
// handle deny client action from authorize-client form
//
// This occurs when user pressed "DENY" button on authorize-client form
// Remove all and redirect to profile
//
request.SetOauth2AuthParams(req.Session, nil)
req.RedirectTo = GetLinks().Profile
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "warning",
Text: t("oauth2-authorize-client.alerts.denied", "client", req.Client.Meta.Name),
})
return
}
// Verifies weather current user can authorize this client or not
func (h *AuthHandlers) canAuthorizeClient(ctx context.Context, c *types.AuthClient) bool {
return systemService.DefaultAccessControl.CanAuthorizeAuthClient(ctx, c)
}
func (h *AuthHandlers) oauth2Token(req *request.AuthReq) (err error) {
// Cleanup
request.SetOauth2ClientAuthorized(req.Session, false)
req.Status = -1
client, err := h.loadRequestedClient(req)
if err != nil {
return h.tokenError(req.Response, err)
}
return h.handleTokenRequest(req, client)
}
// oauth2Info handler validates token and responds with decoded claims
func (h *AuthHandlers) oauth2Info(w http.ResponseWriter, r *http.Request) {
var (
jt jwt.Token
claims map[string]interface{}
)
err := func() (err error) {
if jt, claims, err = jwtauth.FromContext(r.Context()); err != nil {
return
}
if err = auth.TokenIssuer.Validate(r.Context(), jt); err != nil {
return
}
return nil
}()
if err != nil {
if errors.Is(err, context.Canceled) {
// Gracefully handle request cancellation
//
// This happens during the login procedure when browser is
// sent through a couple of quick redirects that can
// terminate requests
w.WriteHeader(http.StatusNoContent)
return
}
var (
data = make(map[string]interface{})
code int
header http.Header
)
if errors.Is(err, oauth2errors.ErrInvalidAccessToken) {
code = http.StatusForbidden
data["error"] = err.Error()
} else {
data, code, header = h.OAuth2.GetErrorData(err)
}
_ = header.Write(w)
w.WriteHeader(code)
data["active"] = false
_ = json.NewEncoder(w).Encode(data)
return
}
_ = json.NewEncoder(w).Encode(claims)
}
// oauth2authorizeDefaultClient acts as a proxy for default client
//
// Responsibilities:
// - handles parameterless request to initialize authorization code flow
// - accepts redirect_uri via query string that's used to build the oauth2 authorization URL
//
// (for the rest of the flow, see oauth2authorizeDefaultClientProc)
func (h *AuthHandlers) oauth2authorizeDefaultClient(req *request.AuthReq) (err error) {
if err = h.verifyDefaultClient(); err != nil {
return
}
var (
params = url.Values{}
)
params.Set("client_id", fmt.Sprintf("%d", h.DefaultClient.ID))
params.Set("response_type", "code")
params.Set("response_mode", "query")
if redir, has := req.Request.Form["redirect_uri"]; has && len(redir) > 0 {
params.Set("redirect_uri", redir[0])
}
if scope, has := req.Request.Form["scope"]; has && len(scope) > 0 {
params.Set("scope", scope[0])
}
if state, has := req.Request.Form["state"]; has && len(state) > 0 {
params.Set("state", state[0])
}
req.RedirectTo = GetLinks().OAuth2Authorize + "?" + params.Encode()
return
}
// oauth2authorizeDefaultClient acts as a proxy for default client
//
// Responsibilities:
// - handles exchange of authorization-code for token
// - handles issuing of new access token requests
//
// (for the first part of the flow, see oauth2authorizeDefaultClient)
func (h *AuthHandlers) oauth2authorizeDefaultClientProc(req *request.AuthReq) (err error) {
if err = h.verifyDefaultClient(); err != nil {
return
}
var (
// Clone of the initial request
// that we'll use for token request validation
r = req.Request.Clone(req.Context())
)
if _, has := r.Form["code"]; has {
r.Form.Set("grant_type", oauth2def.AuthorizationCode.String())
} else if _, has := r.Form["refresh_token"]; has {
r.Form.Set("grant_type", oauth2def.Refreshing.String())
} else {
// make sure we do not get surprised, remove grant_type param
// and let oauth2 internals invalidate the request
r.Form.Del("grant_type")
}
// Add id and secret from the default client
r.SetBasicAuth(
strconv.FormatUint(h.DefaultClient.ID, 10),
h.DefaultClient.Secret,
)
req.Request = r
return h.handleTokenRequest(req, h.DefaultClient)
}
func (h *AuthHandlers) verifyDefaultClient() error {
if h.DefaultClient == nil {
return fmt.Errorf("no default client configured")
}
if err := h.DefaultClient.Verify(); err != nil {
return fmt.Errorf("invalid client: %w", err)
}
return nil
}
// loads client from the request params and verifies other request params against client settings
func (h *AuthHandlers) loadRequestedClient(req *request.AuthReq) (client *types.AuthClient, err error) {
return client, func() (err error) {
var (
id string
found bool
)
if id, _, found = req.Request.BasicAuth(); !found {
if _, found = req.Request.Form["client_id"]; !found {
return
} else {
id = req.Request.Form.Get("client_id")
}
}
client, err = h.ClientService.Lookup(req.Context(), id)
if err != nil {
return fmt.Errorf("invalid client: %w", err)
}
h.Log.Debug("client loaded from store", logger.Uint64("ID", client.ID))
return
}()
}
func (h *AuthHandlers) handleTokenRequest(req *request.AuthReq, client *types.AuthClient) error {
var (
r = req.Request
w = req.Response
ctx = req.Context()
user *types.User
)
req.Status = -1
if err := client.Verify(); err != nil {
return h.tokenError(w, fmt.Errorf("invalid client: %w", err))
}
r = req.Request.Clone(ctx)
gt, tgr, err := h.OAuth2.ValidationTokenRequest(r)
if err != nil {
return h.tokenError(w, err)
}
// Manually set the impersonating user & roles when client credentials
if gt == oauth2def.ClientCredentials {
tgr.UserID = strings.Join(
append([]string{strconv.FormatUint(client.Security.ImpersonateUser, 10)}, 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]
}
sessionUserExists := req.AuthUser != nil && req.AuthUser.User != nil
user, err = h.UserService.FindByAny(suCtx, userID)
if err != nil {
if !errors.Is(err, systemService.UserErrNotFound()) {
return h.tokenError(w, fmt.Errorf("could not generate token: %v", err))
}
if errors.Is(err, systemService.UserErrNotFound()) && sessionUserExists {
user = req.AuthUser.User
}
}
if sessionUserExists && req.AuthUser.User.ID == cast.ToUint64(userID) {
req.AuthUser.User = user
req.AuthUser.Save(req.Session)
}
default:
return fmt.Errorf("unsupported oauth2 grant type: %v", gt)
}
var (
signed []byte
scope = strings.Split(ti.GetScope(), " ")
)
// Here set roles to signed
signed, err = auth.TokenIssuer.Sign(
auth.WithAccessToken(ti.GetAccess()),
auth.WithIdentity(user),
func(tr *auth.TokenRequest) error {
// Calculate user's roles
roles := user.Roles()
if client.Security != nil {
roles = auth.ApplyRoleSecurity(
payload.ParseUint64s(client.Security.PermittedRoles),
payload.ParseUint64s(client.Security.ProhibitedRoles),
payload.ParseUint64s(client.Security.ForcedRoles),
roles...,
)
}
tr.Roles = roles
return nil
},
auth.WithClientID(client.ID),
auth.WithScope(scope...),
)
if err != nil {
return h.tokenError(w, err)
}
// modify token info with signed JWT
// this will be sent back to the user
ti.SetAccess(string(signed))
response := h.OAuth2.GetTokenData(ti)
// include user's avatarID
if user.Meta.AvatarID != 0 {
response["avatarID"] = strconv.FormatUint(user.Meta.AvatarID, 10)
}
if h.Locale.HasLanguage(language.Make(user.Meta.PreferredLanguage)) {
response["preferred_language"] = user.Meta.PreferredLanguage
} else {
response["preferred_language"] = locale.GetAcceptLanguageFromContext(req.Context()).String()
}
if user.Labels != nil {
response["labels"] = user.Labels
} else {
response["labels"] = make(map[string]interface{})
}
//include user's theme
response["theme"] = user.Meta.Theme
// in case client is configured with "openid" scope,
// we'll add "id_token" with all required (by OIDC) details encoded
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 writeResponse(w, data, header, statusCode)
}
func (h *AuthHandlers) oauth2PublicKeys(w http.ResponseWriter, r *http.Request) {
// handle error
handleErr := func(code int, err error) {
var (
data = make(map[string]interface{})
header http.Header
)
data["error"] = err.Error()
_ = header.Write(w)
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(data)
}
// @todo determine weather to get it form user and save it in settings
raw, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
handleErr(http.StatusBadRequest, fmt.Errorf("failed to generate new RSA privatre key: %w", err))
return
}
key, err := jwk.New(raw)
if err != nil {
handleErr(http.StatusInternalServerError, fmt.Errorf("failed to create symmetric key: %w", err))
return
}
publicKey, err := key.PublicKey()
if err != nil {
handleErr(http.StatusInternalServerError, fmt.Errorf("failed to create public key: %w", err))
return
}
_ = json.NewEncoder(w).Encode(publicKey)
}
func SubSplit(ti oauth2def.TokenInfo, data map[string]interface{}) {
userIdWithRoles := strings.SplitN(ti.GetUserID(), " ", 2)
data["sub"] = userIdWithRoles[0]
if len(userIdWithRoles) > 1 {
data["roles"] = strings.Split(userIdWithRoles[1], " ")
}
}
// 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
}
// 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)
}
if err = token.Set("aud", aud); err != nil {
return
}
if err = token.Set("user_id", strconv.FormatUint(user.ID, 10)); err != nil {
return
}
// sub is required by some clients (goth) so we'll provide it just in case
if err = token.Set("sub", strconv.FormatUint(user.ID, 10)); err != nil {
return
}
if err = token.Set("email", user.Email); err != nil {
return
}
if err = token.Set("email_verified", user.EmailConfirmed); err != nil {
return
}
if err = token.Set(jwt.ExpirationKey, now().Add(ti.GetAccessExpiresIn()).Unix()); err != nil {
return
}
if err = token.Set(jwt.IssuedAtKey, now().Unix()); err != nil {
return
}
// profile claims are only added when the token is scoped for them
if auth.CheckScope(ti.GetScope(), "profile") {
for claim, value := range profileClaims(user) {
if err = token.Set(claim, value); err != nil {
return
}
}
}
return jwt.Sign(token, jwa.HS512, []byte(client.Secret))
}
// profileClaims returns OIDC profile-scope claims for the given user
func profileClaims(user *types.User) map[string]interface{} {
cc := make(map[string]interface{})
if user.Name != "" {
cc["name"] = user.Name
}
if user.Handle != "" {
cc["preferred_username"] = user.Handle
}
if user.Meta != nil && user.Meta.PreferredLanguage != "" {
cc["locale"] = user.Meta.PreferredLanguage
}
if user.UpdatedAt != nil {
cc["updated_at"] = user.UpdatedAt.Unix()
} else if !user.CreatedAt.IsZero() {
cc["updated_at"] = user.CreatedAt.Unix()
}
return cc
}
// oauth2Userinfo returns claims about the user the access token was issued for
//
// Claims are filtered by the scope of the token; sub is always included
func (h *AuthHandlers) oauth2Userinfo(w http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
jt jwt.Token
err error
)
if jt, _, err = jwtauth.FromContext(ctx); err != nil || jt == nil {
h.userinfoError(w, fmt.Errorf("unauthorized: %v", err))
return
}
if err = auth.TokenIssuer.Validate(ctx, jt); err != nil {
h.userinfoError(w, err)
return
}
sub, _ := jt.Get("sub")
userID, _ := auth.ExtractFromSubClaim(cast.ToString(sub))
if userID == 0 {
h.userinfoError(w, fmt.Errorf("invalid sub claim"))
return
}
user, err := h.UserService.FindByAny(auth.SetIdentityToContext(ctx, auth.ServiceUser()), userID)
if err != nil {
h.userinfoError(w, fmt.Errorf("could not load user: %w", err))
return
}
scopeClaim, _ := jt.Get("scope")
var (
scope = cast.ToString(scopeClaim)
response = map[string]interface{}{
// sub is the only claim that is always returned
"sub": strconv.FormatUint(user.ID, 10),
}
)
if auth.CheckScope(scope, "profile") {
for claim, value := range profileClaims(user) {
response[claim] = value
}
}
if auth.CheckScope(scope, "email") && user.Email != "" {
response["email"] = user.Email
response["email_verified"] = user.EmailConfirmed
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(response)
}
func (h *AuthHandlers) userinfoError(w http.ResponseWriter, err error) {
h.Log.Warn("userinfo request rejected", zap.Error(err))
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "invalid_token",
"error_description": err.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")
for key := range header {
w.Header().Set(key, header.Get(key))
}
status := http.StatusOK
if len(statusCode) > 0 && statusCode[0] > 0 {
status = statusCode[0]
}
w.WriteHeader(status)
return json.NewEncoder(w).Encode(data)
}