From 45970ae82e478dd7d5f01fcc053de5df00198abc Mon Sep 17 00:00:00 2001
From: "N. L. H" <github@nlh-software.de>
Date: Thu, 6 May 2021 07:30:15 +0200
Subject: [PATCH] Feature/oauth userinfo (#15721)

* Implemented userinfo #8534

* Make lint happy

* Add userinfo endpoint to openid-configuration

* Give an error when uid equals 0

* Implemented BearerTokenErrorCode handling

* instead of ctx.error use ctx.json so that clients
parse error and error_description correctly

* Removed unneeded if statement

* Use switch instead of subsequent if statements
Have a default for unknown errorcodes.

Co-authored-by: Nils Hillmann <hillmann@nlh-software.de>
Co-authored-by: nlhsoftware <nlhsoftware@noreply.localhost>
---
 routers/routes/web.go                   |  1 +
 routers/user/oauth.go                   | 73 +++++++++++++++++++++++++
 templates/user/auth/oidc_wellknown.tmpl |  1 +
 3 files changed, 75 insertions(+)

diff --git a/routers/routes/web.go b/routers/routes/web.go
index ebd738de1e..c4d0bc32f8 100644
--- a/routers/routes/web.go
+++ b/routers/routes/web.go
@@ -410,6 +410,7 @@ func RegisterRoutes(m *web.Route) {
 		// TODO manage redirection
 		m.Post("/authorize", bindIgnErr(forms.AuthorizationForm{}), user.AuthorizeOAuth)
 	}, ignSignInAndCsrf, reqSignIn)
+	m.Get("/login/oauth/userinfo", ignSignInAndCsrf, user.InfoOAuth)
 	if setting.CORSConfig.Enabled {
 		m.Post("/login/oauth/access_token", cors.Handler(cors.Options{
 			//Scheme:           setting.CORSConfig.Scheme, // FIXME: the cors middleware needs scheme option
diff --git a/routers/user/oauth.go b/routers/user/oauth.go
index ae06efd0c0..3ef5a56c01 100644
--- a/routers/user/oauth.go
+++ b/routers/user/oauth.go
@@ -13,6 +13,7 @@ import (
 	"strings"
 
 	"code.gitea.io/gitea/models"
+	"code.gitea.io/gitea/modules/auth/sso"
 	"code.gitea.io/gitea/modules/base"
 	"code.gitea.io/gitea/modules/context"
 	"code.gitea.io/gitea/modules/log"
@@ -93,6 +94,24 @@ func (err AccessTokenError) Error() string {
 	return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
 }
 
+// BearerTokenErrorCode represents an error code specified in RFC 6750
+type BearerTokenErrorCode string
+
+const (
+	// BearerTokenErrorCodeInvalidRequest represents an error code specified in RFC 6750
+	BearerTokenErrorCodeInvalidRequest BearerTokenErrorCode = "invalid_request"
+	// BearerTokenErrorCodeInvalidToken represents an error code specified in RFC 6750
+	BearerTokenErrorCodeInvalidToken BearerTokenErrorCode = "invalid_token"
+	// BearerTokenErrorCodeInsufficientScope represents an error code specified in RFC 6750
+	BearerTokenErrorCodeInsufficientScope BearerTokenErrorCode = "insufficient_scope"
+)
+
+// BearerTokenError represents an error response specified in RFC 6750
+type BearerTokenError struct {
+	ErrorCode        BearerTokenErrorCode `json:"error" form:"error"`
+	ErrorDescription string               `json:"error_description"`
+}
+
 // TokenType specifies the kind of token
 type TokenType string
 
@@ -193,6 +212,45 @@ func newAccessTokenResponse(grant *models.OAuth2Grant, clientSecret string) (*Ac
 	}, nil
 }
 
+type userInfoResponse struct {
+	Sub      string `json:"sub"`
+	Name     string `json:"name"`
+	Username string `json:"preferred_username"`
+	Email    string `json:"email"`
+	Picture  string `json:"picture"`
+}
+
+// InfoOAuth manages request for userinfo endpoint
+func InfoOAuth(ctx *context.Context) {
+	header := ctx.Req.Header.Get("Authorization")
+	auths := strings.Fields(header)
+	if len(auths) != 2 || auths[0] != "Bearer" {
+		ctx.HandleText(http.StatusUnauthorized, "no valid auth token authorization")
+		return
+	}
+	uid := sso.CheckOAuthAccessToken(auths[1])
+	if uid == 0 {
+		handleBearerTokenError(ctx, BearerTokenError{
+			ErrorCode:        BearerTokenErrorCodeInvalidToken,
+			ErrorDescription: "Access token not assigned to any user",
+		})
+		return
+	}
+	authUser, err := models.GetUserByID(uid)
+	if err != nil {
+		ctx.ServerError("GetUserByID", err)
+		return
+	}
+	response := &userInfoResponse{
+		Sub:      fmt.Sprint(authUser.ID),
+		Name:     authUser.FullName,
+		Username: authUser.Name,
+		Email:    authUser.Email,
+		Picture:  authUser.AvatarLink(),
+	}
+	ctx.JSON(http.StatusOK, response)
+}
+
 // AuthorizeOAuth manages authorize requests
 func AuthorizeOAuth(ctx *context.Context) {
 	form := web.GetForm(ctx).(*forms.AuthorizationForm)
@@ -571,3 +629,18 @@ func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirect
 	redirect.RawQuery = q.Encode()
 	ctx.Redirect(redirect.String(), 302)
 }
+
+func handleBearerTokenError(ctx *context.Context, beErr BearerTokenError) {
+	ctx.Resp.Header().Set("WWW-Authenticate", fmt.Sprintf("Bearer realm=\"\", error=\"%s\", error_description=\"%s\"", beErr.ErrorCode, beErr.ErrorDescription))
+	switch beErr.ErrorCode {
+	case BearerTokenErrorCodeInvalidRequest:
+		ctx.JSON(http.StatusBadRequest, beErr)
+	case BearerTokenErrorCodeInvalidToken:
+		ctx.JSON(http.StatusUnauthorized, beErr)
+	case BearerTokenErrorCodeInsufficientScope:
+		ctx.JSON(http.StatusForbidden, beErr)
+	default:
+		log.Error("Invalid BearerTokenErrorCode: %v", beErr.ErrorCode)
+		ctx.ServerError("Unhandled BearerTokenError", fmt.Errorf("BearerTokenError: error=\"%v\", error_description=\"%v\"", beErr.ErrorCode, beErr.ErrorDescription))
+	}
+}
diff --git a/templates/user/auth/oidc_wellknown.tmpl b/templates/user/auth/oidc_wellknown.tmpl
index 290ed4a71d..fcde060a8d 100644
--- a/templates/user/auth/oidc_wellknown.tmpl
+++ b/templates/user/auth/oidc_wellknown.tmpl
@@ -2,6 +2,7 @@
     "issuer": "{{AppUrl | JSEscape | Safe}}",
     "authorization_endpoint": "{{AppUrl | JSEscape | Safe}}login/oauth/authorize",
     "token_endpoint": "{{AppUrl | JSEscape | Safe}}login/oauth/access_token",
+    "userinfo_endpoint": "{{AppUrl | JSEscape | Safe}}login/oauth/userinfo",
     "response_types_supported": [
         "code",
         "id_token"