OAuth2 provider: support for granular scopes

- `CheckOAuthAccessToken` returns both user ID and additional scopes
- `grantAdditionalScopes` returns AccessTokenScope ready string (grantScopes)
   compiled from requested additional scopes by the client
- `userIDFromToken` sets returned grantScopes (if any) instead of default `all`
This commit is contained in:
Marcell Mars 2024-07-11 11:12:51 +02:00
parent 3301e7dc75
commit 4eb8d8c496
4 changed files with 76 additions and 26 deletions

View file

@ -100,6 +100,7 @@ var OAuth2 = struct {
JWTSigningPrivateKeyFile string `ini:"JWT_SIGNING_PRIVATE_KEY_FILE"` JWTSigningPrivateKeyFile string `ini:"JWT_SIGNING_PRIVATE_KEY_FILE"`
MaxTokenLength int MaxTokenLength int
DefaultApplications []string DefaultApplications []string
EnableAdditionalGrantScopes bool
}{ }{
Enabled: true, Enabled: true,
AccessTokenExpirationTime: 3600, AccessTokenExpirationTime: 3600,
@ -109,6 +110,7 @@ var OAuth2 = struct {
JWTSigningPrivateKeyFile: "jwt/private.pem", JWTSigningPrivateKeyFile: "jwt/private.pem",
MaxTokenLength: math.MaxInt16, MaxTokenLength: math.MaxInt16,
DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea"}, DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea"},
EnableAdditionalGrantScopes: false,
} }
func loadOAuth2From(rootCfg ConfigProvider) { func loadOAuth2From(rootCfg ConfigProvider) {

View file

@ -110,5 +110,6 @@ func loadApplicationsData(ctx *context.Context) {
ctx.ServerError("GetOAuth2GrantsByUserID", err) ctx.ServerError("GetOAuth2GrantsByUserID", err)
return return
} }
ctx.Data["EnableAdditionalGrantScopes"] = setting.OAuth2.EnableAdditionalGrantScopes
} }
} }

View file

@ -72,7 +72,7 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
} }
// check oauth2 token // check oauth2 token
uid := CheckOAuthAccessToken(req.Context(), authToken) uid, _ := CheckOAuthAccessToken(req.Context(), authToken)
if uid != 0 { if uid != 0 {
log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid) log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid)

View file

@ -7,6 +7,7 @@ package auth
import ( import (
"context" "context"
"net/http" "net/http"
"slices"
"strings" "strings"
"time" "time"
@ -25,28 +26,69 @@ var (
_ Method = &OAuth2{} _ Method = &OAuth2{}
) )
// grantAdditionalScopes returns valid scopes coming from grant
func grantAdditionalScopes(grantScopes string) string {
// scopes_supported from templates/user/auth/oidc_wellknown.tmpl
scopesSupported := []string{
"openid",
"profile",
"email",
"groups",
}
var apiTokenScopes []string
for _, apiTokenScope := range strings.Split(grantScopes, " ") {
if slices.Index(scopesSupported, apiTokenScope) == -1 {
apiTokenScopes = append(apiTokenScopes, apiTokenScope)
}
}
if len(apiTokenScopes) == 0 {
return ""
}
var additionalGrantScopes []string
allScopes := auth_model.AccessTokenScope("all")
for _, apiTokenScope := range apiTokenScopes {
grantScope := auth_model.AccessTokenScope(apiTokenScope)
if ok, _ := allScopes.HasScope(grantScope); ok {
additionalGrantScopes = append(additionalGrantScopes, apiTokenScope)
} else if apiTokenScope == "public-only" {
additionalGrantScopes = append(additionalGrantScopes, apiTokenScope)
}
}
if len(additionalGrantScopes) > 0 {
return strings.Join(additionalGrantScopes, ",")
}
return ""
}
// CheckOAuthAccessToken returns uid of user from oauth token // CheckOAuthAccessToken returns uid of user from oauth token
func CheckOAuthAccessToken(ctx context.Context, accessToken string) int64 { // + non default openid scopes requested
func CheckOAuthAccessToken(ctx context.Context, accessToken string) (int64, string) {
// JWT tokens require a "." // JWT tokens require a "."
if !strings.Contains(accessToken, ".") { if !strings.Contains(accessToken, ".") {
return 0 return 0, ""
} }
token, err := oauth2.ParseToken(accessToken, oauth2.DefaultSigningKey) token, err := oauth2.ParseToken(accessToken, oauth2.DefaultSigningKey)
if err != nil { if err != nil {
log.Trace("oauth2.ParseToken: %v", err) log.Trace("oauth2.ParseToken: %v", err)
return 0 return 0, ""
} }
var grant *auth_model.OAuth2Grant var grant *auth_model.OAuth2Grant
if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil { if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
return 0 return 0, ""
} }
if token.Type != oauth2.TypeAccessToken { if token.Type != oauth2.TypeAccessToken {
return 0 return 0, ""
} }
if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) { if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {
return 0 return 0, ""
} }
return grant.UserID grantScopes := grantAdditionalScopes(grant.Scope)
return grant.UserID, grantScopes
} }
// OAuth2 implements the Auth interface and authenticates requests // OAuth2 implements the Auth interface and authenticates requests
@ -92,11 +134,16 @@ func parseToken(req *http.Request) (string, bool) {
func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store DataStore) int64 { func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store DataStore) int64 {
// Let's see if token is valid. // Let's see if token is valid.
if strings.Contains(tokenSHA, ".") { if strings.Contains(tokenSHA, ".") {
uid := CheckOAuthAccessToken(ctx, tokenSHA) uid, grantScopes := CheckOAuthAccessToken(ctx, tokenSHA)
if uid != 0 { if uid != 0 {
store.GetData()["IsApiToken"] = true store.GetData()["IsApiToken"] = true
if grantScopes != "" {
store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScope(grantScopes)
} else {
store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all
} }
}
return uid return uid
} }
t, err := auth_model.GetAccessTokenBySHA(ctx, tokenSHA) t, err := auth_model.GetAccessTokenBySHA(ctx, tokenSHA)