snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
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
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
This commit is contained in:
708
server/auth/handlers/handle_oauth2.go
Normal file
708
server/auth/handlers/handle_oauth2.go
Normal file
@@ -0,0 +1,708 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user