Auth: Extended JWT client for OBO and Service Authentication (#83814)

* reenable ext-jwt-client

* fixup settings struct

* add user and service auth

* lint up

* add user auth to grafana ext

* fixes

* Populate token permissions

Co-authored-by: jguer <joao.guerreiro@grafana.com>

* fix tests

* fix lint

* small prealloc

* small prealloc

* use special namespace for access policies

* fix access policy auth

* fix tests

* fix uncalled settings expander

* add feature toggle

* small feedback fixes

* rename entitlements to permissions

* add authlibn

* allow viewing the signed in user info for non user namespace

* fix invalid namespacedID

* use authlib as verifier for tokens

* Update pkg/services/authn/clients/ext_jwt.go

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>

* Update pkg/services/authn/clients/ext_jwt_test.go

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>

* fix parameter names

* change asserts to normal package

* add rule for assert

* fix ownerships

* Local diff

* test and lint

* Fix test

* Fix ac test

* Fix pluginproxy test

* Revert testdata changes

* Force revert on test data

---------

Co-authored-by: gamab <gabriel.mabille@grafana.com>
Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>
This commit is contained in:
Jo 2024-04-02 17:45:15 +02:00 committed by GitHub
parent ac6e51c94a
commit 5340a6e548
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 443 additions and 326 deletions

View File

@ -17,6 +17,7 @@ deny = [
{ pkg = "github.com/pkg/errors", desc = "Deprecated: Go 1.13 supports the functionality provided by pkg/errors in the standard library." }, { pkg = "github.com/pkg/errors", desc = "Deprecated: Go 1.13 supports the functionality provided by pkg/errors in the standard library." },
{ pkg = "github.com/xorcare/pointer", desc = "Use pkg/util.Pointer instead, which is a generic one-liner alternative" }, { pkg = "github.com/xorcare/pointer", desc = "Use pkg/util.Pointer instead, which is a generic one-liner alternative" },
{ pkg = "github.com/gofrs/uuid", desc = "Use github.com/google/uuid instead, which we already depend on." }, { pkg = "github.com/gofrs/uuid", desc = "Use github.com/google/uuid instead, which we already depend on." },
{ pkg = "github.com/bmizerany/assert", desc = "Use github.com/stretchr/testify/assert instead, which we already depend on." },
] ]
[linters-settings.depguard.rules.coreplugins] [linters-settings.depguard.rules.coreplugins]

16
go.mod
View File

@ -20,7 +20,7 @@ require (
cuelang.org/go v0.6.0-0.dev // @grafana/grafana-as-code cuelang.org/go v0.6.0-0.dev // @grafana/grafana-as-code
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // @grafana/partner-datasources github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // @grafana/partner-datasources
github.com/Azure/go-autorest/autorest v0.11.29 // @grafana/backend-platform github.com/Azure/go-autorest/autorest v0.11.29 // @grafana/backend-platform
github.com/BurntSushi/toml v1.3.2 // @grafana/grafana-authnz-team github.com/BurntSushi/toml v1.3.2 // @grafana/identity-access-team
github.com/Masterminds/semver v1.5.0 // @grafana/backend-platform github.com/Masterminds/semver v1.5.0 // @grafana/backend-platform
github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // @grafana/backend-platform github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // @grafana/backend-platform
github.com/aws/aws-sdk-go v1.50.8 // @grafana/aws-datasources github.com/aws/aws-sdk-go v1.50.8 // @grafana/aws-datasources
@ -29,10 +29,10 @@ require (
github.com/blang/semver/v4 v4.0.0 // @grafana/grafana-release-guild github.com/blang/semver/v4 v4.0.0 // @grafana/grafana-release-guild
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b // @grafana/backend-platform github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b // @grafana/backend-platform
github.com/centrifugal/centrifuge v0.30.2 // @grafana/grafana-app-platform-squad github.com/centrifugal/centrifuge v0.30.2 // @grafana/grafana-app-platform-squad
github.com/crewjam/saml v0.4.13 // @grafana/grafana-authnz-team github.com/crewjam/saml v0.4.13 // @grafana/identity-access-team
github.com/fatih/color v1.15.0 // @grafana/backend-platform github.com/fatih/color v1.15.0 // @grafana/backend-platform
github.com/gchaincl/sqlhooks v1.3.0 // @grafana/backend-platform github.com/gchaincl/sqlhooks v1.3.0 // @grafana/backend-platform
github.com/go-ldap/ldap/v3 v3.4.4 // @grafana/grafana-authnz-team github.com/go-ldap/ldap/v3 v3.4.4 // @grafana/identity-access-team
github.com/go-openapi/strfmt v0.22.0 // @grafana/alerting-squad-backend github.com/go-openapi/strfmt v0.22.0 // @grafana/alerting-squad-backend
github.com/go-redis/redis/v8 v8.11.5 // @grafana/backend-platform github.com/go-redis/redis/v8 v8.11.5 // @grafana/backend-platform
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // @grafana/backend-platform github.com/go-sourcemap/sourcemap v2.1.3+incompatible // @grafana/backend-platform
@ -96,7 +96,7 @@ require (
golang.org/x/crypto v0.21.0 // @grafana/backend-platform golang.org/x/crypto v0.21.0 // @grafana/backend-platform
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // @grafana/alerting-squad-backend golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // @grafana/alerting-squad-backend
golang.org/x/net v0.22.0 // @grafana/oss-big-tent @grafana/partner-datasources golang.org/x/net v0.22.0 // @grafana/oss-big-tent @grafana/partner-datasources
golang.org/x/oauth2 v0.18.0 // @grafana/grafana-authnz-team golang.org/x/oauth2 v0.18.0 // @grafana/identity-access-team
golang.org/x/sync v0.6.0 // @grafana/alerting-squad-backend golang.org/x/sync v0.6.0 // @grafana/alerting-squad-backend
golang.org/x/time v0.5.0 // @grafana/backend-platform golang.org/x/time v0.5.0 // @grafana/backend-platform
golang.org/x/tools v0.17.0 // @grafana/grafana-as-code golang.org/x/tools v0.17.0 // @grafana/grafana-as-code
@ -241,7 +241,7 @@ require (
github.com/Masterminds/semver/v3 v3.1.1 // @grafana/grafana-release-guild github.com/Masterminds/semver/v3 v3.1.1 // @grafana/grafana-release-guild
github.com/alicebob/miniredis/v2 v2.30.1 // @grafana/alerting-squad-backend github.com/alicebob/miniredis/v2 v2.30.1 // @grafana/alerting-squad-backend
github.com/dave/dst v0.27.2 // @grafana/grafana-as-code github.com/dave/dst v0.27.2 // @grafana/grafana-as-code
github.com/go-jose/go-jose/v3 v3.0.3 // @grafana/grafana-authnz-team github.com/go-jose/go-jose/v3 v3.0.3 // @grafana/identity-access-team
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
github.com/grafana/dataplane/sdata v0.0.7 // @grafana/observability-metrics github.com/grafana/dataplane/sdata v0.0.7 // @grafana/observability-metrics
github.com/grafana/tempo v1.5.1-0.20230524121406-1dc1bfe7085b // @grafana/observability-traces-and-profiling github.com/grafana/tempo v1.5.1-0.20230524121406-1dc1bfe7085b // @grafana/observability-traces-and-profiling
@ -326,7 +326,7 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-ieproxy v0.0.3 // indirect github.com/mattn/go-ieproxy v0.0.3 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 //@grafana/grafana-authnz-team github.com/mitchellh/mapstructure v1.5.0 //@grafana/identity-access-team
github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // @grafana/alerting-squad-backend github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // @grafana/alerting-squad-backend
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
@ -384,7 +384,7 @@ require (
require ( require (
cloud.google.com/go/compute v1.23.3 // indirect cloud.google.com/go/compute v1.23.3 // indirect
cloud.google.com/go/iam v1.1.5 // indirect cloud.google.com/go/iam v1.1.5 // indirect
filippo.io/age v1.1.1 // @grafana/grafana-authnz-team filippo.io/age v1.1.1 // @grafana/identity-access-team
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.10.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.10.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1 // indirect
@ -473,7 +473,7 @@ require github.com/jackc/pgx/v5 v5.5.5 // @grafana/oss-big-tent
require github.com/getkin/kin-openapi v0.120.0 // @grafana/grafana-as-code require github.com/getkin/kin-openapi v0.120.0 // @grafana/grafana-as-code
require github.com/grafana/authlib v0.0.0-20240319083410-9d4a6e3861e5 // @grafana/grafana-app-platform-squad require github.com/grafana/authlib v0.0.0-20240328140636-a7388d0bac72 // @grafana/identity-access-team
require ( require (
github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect

4
go.sum
View File

@ -2161,8 +2161,8 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/alerting v0.0.0-20240322221449-89ae4e299bf8 h1:ndBSFAHmJRWqln2uNys7lV0+9U8tlW6ZuNz8ETW60Us= github.com/grafana/alerting v0.0.0-20240322221449-89ae4e299bf8 h1:ndBSFAHmJRWqln2uNys7lV0+9U8tlW6ZuNz8ETW60Us=
github.com/grafana/alerting v0.0.0-20240322221449-89ae4e299bf8/go.mod h1:0nHKO0w8OTemvZ3eh7+s1EqGGhgbs0kvkTeLU1FrbTw= github.com/grafana/alerting v0.0.0-20240322221449-89ae4e299bf8/go.mod h1:0nHKO0w8OTemvZ3eh7+s1EqGGhgbs0kvkTeLU1FrbTw=
github.com/grafana/authlib v0.0.0-20240319083410-9d4a6e3861e5 h1:A13Z8Hy60BfIduM819kpk0njrRKjbAVbVRhE+R+AF/8= github.com/grafana/authlib v0.0.0-20240328140636-a7388d0bac72 h1:lGEuhD/KhhN1OiPrvwQejl9Lg8MvaHdj3lHZNref4is=
github.com/grafana/authlib v0.0.0-20240319083410-9d4a6e3861e5/go.mod h1:86rRD5P6u2JPWtNWTMOlqlU+YMv2fUvVz/DomA6L7w4= github.com/grafana/authlib v0.0.0-20240328140636-a7388d0bac72/go.mod h1:86rRD5P6u2JPWtNWTMOlqlU+YMv2fUvVz/DomA6L7w4=
github.com/grafana/codejen v0.0.3 h1:tAWxoTUuhgmEqxJPOLtJoxlPBbMULFwKFOcRsPRPXDw= github.com/grafana/codejen v0.0.3 h1:tAWxoTUuhgmEqxJPOLtJoxlPBbMULFwKFOcRsPRPXDw=
github.com/grafana/codejen v0.0.3/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s= github.com/grafana/codejen v0.0.3/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s=
github.com/grafana/cue v0.0.0-20230926092038-971951014e3f h1:TmYAMnqg3d5KYEAaT6PtTguL2GjLfvr6wnAX8Azw6tQ= github.com/grafana/cue v0.0.0-20230926092038-971951014e3f h1:TmYAMnqg3d5KYEAaT6PtTguL2GjLfvr6wnAX8Azw6tQ=

View File

@ -351,6 +351,7 @@ github.com/go-fonts/stix v0.1.0 h1:UlZlgrvvmT/58o573ot7NFw0vZasZ5I6bcIft/oMdgg=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I=
github.com/go-ini/ini v1.25.4 h1:Mujh4R/dH6YL8bxuISne3xX2+qcQ9p0IxKAP6ExWoUo= github.com/go-ini/ini v1.25.4 h1:Mujh4R/dH6YL8bxuISne3xX2+qcQ9p0IxKAP6ExWoUo=
github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-kit/kit v0.12.0 h1:e4o3o3IsBfAKQh5Qbbiqyfu97Ku7jrO/JbohvztANh4= github.com/go-kit/kit v0.12.0 h1:e4o3o3IsBfAKQh5Qbbiqyfu97Ku7jrO/JbohvztANh4=
github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81 h1:6zl3BbBhdnMkpSj2YY30qV3gDcVBGtFgVsV3+/i+mKQ= github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81 h1:6zl3BbBhdnMkpSj2YY30qV3gDcVBGtFgVsV3+/i+mKQ=
github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab h1:xveKWz2iaueeTaUgdetzel+U7exyigDYBryyVfV/rZk= github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab h1:xveKWz2iaueeTaUgdetzel+U7exyigDYBryyVfV/rZk=
@ -405,6 +406,9 @@ github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/grafana/authlib v0.0.0-20240319083410-9d4a6e3861e5/go.mod h1:86rRD5P6u2JPWtNWTMOlqlU+YMv2fUvVz/DomA6L7w4=
github.com/grafana/authlib v0.0.0-20240328140636-a7388d0bac72 h1:lGEuhD/KhhN1OiPrvwQejl9Lg8MvaHdj3lHZNref4is=
github.com/grafana/authlib v0.0.0-20240328140636-a7388d0bac72/go.mod h1:86rRD5P6u2JPWtNWTMOlqlU+YMv2fUvVz/DomA6L7w4=
github.com/grafana/e2e v0.1.1-0.20221018202458-cffd2bb71c7b h1:Ha+kSIoTutf4ytlVw/SaEclDUloYx0+FXDKJWKhNbE4= github.com/grafana/e2e v0.1.1-0.20221018202458-cffd2bb71c7b h1:Ha+kSIoTutf4ytlVw/SaEclDUloYx0+FXDKJWKhNbE4=
github.com/grafana/e2e v0.1.1-0.20221018202458-cffd2bb71c7b/go.mod h1:3UsooRp7yW5/NJQBlXcTsAHOoykEhNUYXkQ3r6ehEEY= github.com/grafana/e2e v0.1.1-0.20221018202458-cffd2bb71c7b/go.mod h1:3UsooRp7yW5/NJQBlXcTsAHOoykEhNUYXkQ3r6ehEEY=
github.com/grafana/gomemcache v0.0.0-20231023152154-6947259a0586 h1:/of8Z8taCPftShATouOrBVy6GaTTjgQd/VfNiZp/VXQ= github.com/grafana/gomemcache v0.0.0-20231023152154-6947259a0586 h1:/of8Z8taCPftShATouOrBVy6GaTTjgQd/VfNiZp/VXQ=
@ -661,6 +665,7 @@ github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad h1:fiWzISvDn0Csy5H0iwgAuJ
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
github.com/streadway/amqp v1.0.0 h1:kuuDrUJFZL1QYL9hUNuCxNObNzB0bV/ZG5jV3RWAQgo= github.com/streadway/amqp v1.0.0 h1:kuuDrUJFZL1QYL9hUNuCxNObNzB0bV/ZG5jV3RWAQgo=
github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e h1:mOtuXaRAbVZsxAHVdPR3IjfmN8T1h2iczJLynhLybf8= github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e h1:mOtuXaRAbVZsxAHVdPR3IjfmN8T1h2iczJLynhLybf8=
github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0=
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
github.com/substrait-io/substrait-go v0.4.2 h1:buDnjsb3qAqTaNbOR7VKmNgXf4lYQxWEcnSGUWBtmN8= github.com/substrait-io/substrait-go v0.4.2 h1:buDnjsb3qAqTaNbOR7VKmNgXf4lYQxWEcnSGUWBtmN8=
github.com/substrait-io/substrait-go v0.4.2/go.mod h1:qhpnLmrcvAnlZsUyPXZRqldiHapPTXC3t7xFgDi3aQg= github.com/substrait-io/substrait-go v0.4.2/go.mod h1:qhpnLmrcvAnlZsUyPXZRqldiHapPTXC3t7xFgDi3aQg=
@ -771,6 +776,7 @@ go.uber.org/mock v0.2.0/go.mod h1:J0y0rp9L3xiff1+ZBfKxlC1fz2+aO16tw0tsDOixfuM=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=
go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=

View File

@ -173,6 +173,7 @@ export interface FeatureToggles {
expressionParser?: boolean; expressionParser?: boolean;
groupByVariable?: boolean; groupByVariable?: boolean;
betterPageScrolling?: boolean; betterPageScrolling?: boolean;
authAPIAccessTokenAuth?: boolean;
scopeFilters?: boolean; scopeFilters?: boolean;
ssoSettingsSAML?: boolean; ssoSettingsSAML?: boolean;
usePrometheusFrontendPackage?: boolean; usePrometheusFrontendPackage?: boolean;

View File

@ -513,7 +513,8 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
t, t,
&contextmodel.ReqContext{ &contextmodel.ReqContext{
SignedInUser: &user.SignedInUser{ SignedInUser: &user.SignedInUser{
Login: "test_user", Login: "test_user",
NamespacedID: "user:1",
}, },
}, },
&setting.Cfg{SendUserHeader: true}, &setting.Cfg{SendUserHeader: true},

View File

@ -76,7 +76,8 @@ func TestPluginProxy(t *testing.T) {
secretsService, secretsService,
&contextmodel.ReqContext{ &contextmodel.ReqContext{
SignedInUser: &user.SignedInUser{ SignedInUser: &user.SignedInUser{
Login: "test_user", Login: "test_user",
NamespacedID: "user:1",
}, },
Context: &web.Context{ Context: &web.Context{
Req: httpReq, Req: httpReq,

View File

@ -31,10 +31,23 @@ import (
// 404: notFoundError // 404: notFoundError
// 500: internalServerError // 500: internalServerError
func (hs *HTTPServer) GetSignedInUser(c *contextmodel.ReqContext) response.Response { func (hs *HTTPServer) GetSignedInUser(c *contextmodel.ReqContext) response.Response {
userID, errResponse := getUserID(c) namespace, identifier := c.SignedInUser.GetNamespacedID()
if errResponse != nil { if namespace != identity.NamespaceUser {
return errResponse return response.JSON(http.StatusOK, user.UserProfileDTO{
IsGrafanaAdmin: c.SignedInUser.GetIsGrafanaAdmin(),
OrgID: c.SignedInUser.GetOrgID(),
UID: strings.Join([]string{namespace, identifier}, ":"),
Name: c.SignedInUser.NameOrFallback(),
Email: c.SignedInUser.GetEmail(),
Login: c.SignedInUser.GetLogin(),
})
} }
userID, err := identity.IntIdentifier(namespace, identifier)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to parse user id", err)
}
return hs.getUserUserProfile(c, userID) return hs.getUserUserProfile(c, userID)
} }

View File

@ -24,6 +24,8 @@ type AccessControl interface {
type Service interface { type Service interface {
registry.ProvidesUsageStats registry.ProvidesUsageStats
// GetRoleByName returns a role by name
GetRoleByName(ctx context.Context, orgID int64, roleName string) (*RoleDTO, error)
// GetUserPermissions returns user permissions with only action and scope fields set. // GetUserPermissions returns user permissions with only action and scope fields set.
GetUserPermissions(ctx context.Context, user identity.Requester, options Options) ([]Permission, error) GetUserPermissions(ctx context.Context, user identity.Requester, options Options) ([]Permission, error)
// GetUserPermissionsInOrg return user permission in a specific organization // GetUserPermissionsInOrg return user permission in a specific organization

View File

@ -504,3 +504,26 @@ func (s *Service) DeleteExternalServiceRole(ctx context.Context, externalService
func (*Service) SyncUserRoles(ctx context.Context, orgID int64, cmd accesscontrol.SyncUserRolesCommand) error { func (*Service) SyncUserRoles(ctx context.Context, orgID int64, cmd accesscontrol.SyncUserRolesCommand) error {
return nil return nil
} }
func (s *Service) GetRoleByName(ctx context.Context, orgID int64, roleName string) (*accesscontrol.RoleDTO, error) {
err := accesscontrol.ErrRoleNotFound
if _, ok := s.roles[roleName]; ok {
return nil, err
}
var role *accesscontrol.RoleDTO
s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool {
if registration.Role.Name == roleName {
role = &accesscontrol.RoleDTO{
Name: registration.Role.Name,
Permissions: registration.Role.Permissions,
DisplayName: registration.Role.DisplayName,
Description: registration.Role.Description,
}
err = nil
return false
}
return true
})
return role, err
}

View File

@ -754,8 +754,9 @@ func TestPermissionCacheKey(t *testing.T) {
{ {
name: "should return correct key for user", name: "should return correct key for user",
signedInUser: &user.SignedInUser{ signedInUser: &user.SignedInUser{
OrgID: 1, OrgID: 1,
UserID: 1, UserID: 1,
NamespacedID: "user:1",
}, },
expected: "rbac-permissions-1-user-1", expected: "rbac-permissions-1-user-1",
}, },
@ -765,6 +766,7 @@ func TestPermissionCacheKey(t *testing.T) {
OrgID: 1, OrgID: 1,
ApiKeyID: 1, ApiKeyID: 1,
IsServiceAccount: false, IsServiceAccount: false,
NamespacedID: "user:1",
}, },
expected: "rbac-permissions-1-api-key-1", expected: "rbac-permissions-1-api-key-1",
}, },
@ -774,6 +776,7 @@ func TestPermissionCacheKey(t *testing.T) {
OrgID: 1, OrgID: 1,
UserID: 1, UserID: 1,
IsServiceAccount: true, IsServiceAccount: true,
NamespacedID: "serviceaccount:1",
}, },
expected: "rbac-permissions-1-service-account-1", expected: "rbac-permissions-1-service-account-1",
}, },
@ -783,14 +786,16 @@ func TestPermissionCacheKey(t *testing.T) {
OrgID: 1, OrgID: 1,
UserID: -1, UserID: -1,
IsServiceAccount: true, IsServiceAccount: true,
NamespacedID: "serviceaccount:-1",
}, },
expected: "rbac-permissions-1-service-account--1", expected: "rbac-permissions-1-service-account--1",
}, },
{ {
name: "should use org role if no unique id", name: "should use org role if no unique id",
signedInUser: &user.SignedInUser{ signedInUser: &user.SignedInUser{
OrgID: 1, OrgID: 1,
OrgRole: org.RoleNone, OrgRole: org.RoleNone,
NamespacedID: "user:1",
}, },
expected: "rbac-permissions-1-user-None", expected: "rbac-permissions-1-user-None",
}, },

View File

@ -20,6 +20,7 @@ type fullAccessControl interface {
type Calls struct { type Calls struct {
Evaluate []interface{} Evaluate []interface{}
GetRoleByName []interface{}
GetUserPermissions []interface{} GetUserPermissions []interface{}
GetUserPermissionsInOrg []interface{} GetUserPermissionsInOrg []interface{}
ClearUserPermissionCache []interface{} ClearUserPermissionCache []interface{}
@ -47,6 +48,7 @@ type Mock struct {
// Override functions // Override functions
EvaluateFunc func(context.Context, identity.Requester, accesscontrol.Evaluator) (bool, error) EvaluateFunc func(context.Context, identity.Requester, accesscontrol.Evaluator) (bool, error)
GetRoleByNameFunc func(context.Context, int64, string) (*accesscontrol.RoleDTO, error)
GetUserPermissionsFunc func(context.Context, identity.Requester, accesscontrol.Options) ([]accesscontrol.Permission, error) GetUserPermissionsFunc func(context.Context, identity.Requester, accesscontrol.Options) ([]accesscontrol.Permission, error)
GetUserPermissionsInOrgFunc func(context.Context, identity.Requester, int64) ([]accesscontrol.Permission, error) GetUserPermissionsInOrgFunc func(context.Context, identity.Requester, int64) ([]accesscontrol.Permission, error)
ClearUserPermissionCacheFunc func(identity.Requester) ClearUserPermissionCacheFunc func(identity.Requester)
@ -81,6 +83,14 @@ func New() *Mock {
return mock return mock
} }
func (m *Mock) GetRoleByName(ctx context.Context, orgID int64, roleName string) (*accesscontrol.RoleDTO, error) {
m.Calls.GetRoleByName = append(m.Calls.GetRoleByName, []interface{}{ctx, orgID, roleName})
if m.GetRoleByNameFunc != nil {
return m.GetRoleByNameFunc(ctx, orgID, roleName)
}
return nil, nil
}
func (m *Mock) GetUsageStats(ctx context.Context) map[string]interface{} { func (m *Mock) GetUsageStats(ctx context.Context) map[string]interface{} {
return make(map[string]interface{}) return make(map[string]interface{})
} }

View File

@ -14,6 +14,7 @@ const (
NamespaceServiceAccount = "service-account" NamespaceServiceAccount = "service-account"
NamespaceAnonymous = "anonymous" NamespaceAnonymous = "anonymous"
NamespaceRenderService = "render" NamespaceRenderService = "render"
NamespaceAccessPolicy = "access-policy"
) )
var ErrNotIntIdentifier = errors.New("identifier is not an int64") var ErrNotIntIdentifier = errors.New("identifier is not an int64")

View File

@ -57,6 +57,15 @@ type ClientParams struct {
LookUpParams login.UserLookupParams LookUpParams login.UserLookupParams
// SyncPermissions ensure that permissions are loaded from DB and added to the identity // SyncPermissions ensure that permissions are loaded from DB and added to the identity
SyncPermissions bool SyncPermissions bool
// FetchPermissionsParams are the arguments used to fetch permissions from the DB
FetchPermissionsParams FetchPermissionsParams
}
type FetchPermissionsParams struct {
// ActionsLookup will restrict the permissions to only these actions
ActionsLookup []string
// Roles permissions will be directly added to the identity permissions
Roles []string
} }
type PostAuthHookFn func(ctx context.Context, identity *Identity, r *Request) error type PostAuthHookFn func(ctx context.Context, identity *Identity, r *Request) error

View File

@ -135,10 +135,9 @@ func ProvideService(
s.RegisterClient(clients.ProvideJWT(jwtService, cfg)) s.RegisterClient(clients.ProvideJWT(jwtService, cfg))
} }
// FIXME (gamab): Commenting that out for now as we want to re-use the client for external service auth if s.cfg.ExtJWTAuth.Enabled && features.IsEnabledGlobally(featuremgmt.FlagAuthAPIAccessTokenAuth) {
// if s.cfg.ExtendedJWTAuthEnabled && features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) { s.RegisterClient(clients.ProvideExtendedJWT(userService, cfg, signingKeysService))
// s.RegisterClient(clients.ProvideExtendedJWT(userService, cfg, signingKeysService, oauthServer)) }
// }
for name := range socialService.GetOAuthProviders() { for name := range socialService.GetOAuthProviders() {
clientName := authn.ClientWithPrefix(name) clientName := authn.ClientWithPrefix(name)

View File

@ -2,6 +2,7 @@ package sync
import ( import (
"context" "context"
"errors"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol"
@ -34,19 +35,57 @@ func (s *RBACSync) SyncPermissionsHook(ctx context.Context, ident *authn.Identit
return nil return nil
} }
permissions, err := s.ac.GetUserPermissions(ctx, ident, accesscontrol.Options{ReloadCache: false}) // Populate permissions from roles
permissions, err := s.fetchPermissions(ctx, ident)
if err != nil { if err != nil {
s.log.FromContext(ctx).Error("Failed to fetch permissions from db", "error", err, "id", ident.ID) return err
return errSyncPermissionsForbidden
} }
if ident.Permissions == nil { if ident.Permissions == nil {
ident.Permissions = make(map[int64]map[string][]string) ident.Permissions = make(map[int64]map[string][]string, 1)
} }
ident.Permissions[ident.OrgID] = accesscontrol.GroupScopesByAction(permissions) grouped := accesscontrol.GroupScopesByAction(permissions)
// Restrict access to the list of actions
actionsLookup := ident.ClientParams.FetchPermissionsParams.ActionsLookup
if len(actionsLookup) > 0 {
filtered := make(map[string][]string, len(actionsLookup))
for _, action := range actionsLookup {
if scopes, ok := grouped[action]; ok {
filtered[action] = scopes
}
}
grouped = filtered
}
ident.Permissions[ident.OrgID] = grouped
return nil return nil
} }
func (s *RBACSync) fetchPermissions(ctx context.Context, ident *authn.Identity) ([]accesscontrol.Permission, error) {
permissions := make([]accesscontrol.Permission, 0, 8)
roles := ident.ClientParams.FetchPermissionsParams.Roles
if len(roles) > 0 {
for _, role := range roles {
roleDTO, err := s.ac.GetRoleByName(ctx, ident.GetOrgID(), role)
if err != nil && !errors.Is(err, accesscontrol.ErrRoleNotFound) {
s.log.FromContext(ctx).Error("Failed to fetch role from db", "error", err, "role", role)
return nil, errSyncPermissionsForbidden
}
permissions = append(permissions, roleDTO.Permissions...)
}
return permissions, nil
}
permissions, err := s.ac.GetUserPermissions(ctx, ident, accesscontrol.Options{ReloadCache: false})
if err != nil {
s.log.FromContext(ctx).Error("Failed to fetch permissions from db", "error", err, "id", ident.ID)
return nil, errSyncPermissionsForbidden
}
return permissions, nil
}
var fixedCloudRoles = map[org.RoleType]string{ var fixedCloudRoles = map[org.RoleType]string{
org.RoleViewer: accesscontrol.FixedCloudViewerRole, org.RoleViewer: accesscontrol.FixedCloudViewerRole,
org.RoleEditor: accesscontrol.FixedCloudEditorRole, org.RoleEditor: accesscontrol.FixedCloudEditorRole,

View File

@ -7,11 +7,11 @@ import (
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/go-jose/go-jose/v3"
"github.com/go-jose/go-jose/v3/jwt" "github.com/go-jose/go-jose/v3/jwt"
authlib "github.com/grafana/authlib/authn"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login"
@ -24,20 +24,29 @@ var _ authn.Client = new(ExtendedJWT)
var ( var (
acceptedSigningMethods = []string{"RS256", "ES256"} acceptedSigningMethods = []string{"RS256", "ES256"}
timeNow = time.Now
) )
const ( const (
rfc9068ShortMediaType = "at+jwt" rfc9068ShortMediaType = "at+jwt"
rfc9068MediaType = "application/at+jwt" extJWTAuthenticationHeaderName = "X-Access-Token"
extJWTAuthorizationHeaderName = "X-Grafana-Id"
) )
func ProvideExtendedJWT(userService user.Service, cfg *setting.Cfg, signingKeys signingkeys.Service) *ExtendedJWT { func ProvideExtendedJWT(userService user.Service, cfg *setting.Cfg,
signingKeys signingkeys.Service) *ExtendedJWT {
verifier := authlib.NewVerifier[ExtendedJWTClaims](authlib.IDVerifierConfig{
SigningKeysURL: cfg.ExtJWTAuth.JWKSUrl,
AllowedAudiences: []string{
cfg.ExtJWTAuth.ExpectAudience,
},
})
return &ExtendedJWT{ return &ExtendedJWT{
cfg: cfg, cfg: cfg,
log: log.New(authn.ClientExtendedJWT), log: log.New(authn.ClientExtendedJWT),
userService: userService, userService: userService,
signingKeys: signingKeys, signingKeys: signingKeys,
verifier: verifier,
} }
} }
@ -46,68 +55,97 @@ type ExtendedJWT struct {
log log.Logger log log.Logger
userService user.Service userService user.Service
signingKeys signingkeys.Service signingKeys signingkeys.Service
verifier authlib.Verifier[ExtendedJWTClaims]
} }
type ExtendedJWTClaims struct { type ExtendedJWTClaims struct {
jwt.Claims jwt.Claims
ClientID string `json:"client_id"` // Access policy scopes
Groups []string `json:"groups"` Scopes []string `json:"scopes"`
Email string `json:"email"` // Grafana roles
Name string `json:"name"` Permissions []string `json:"permissions"`
Login string `json:"login"` // On-behalf-of user
Scopes []string `json:"scope"` DelegatedPermissions []string `json:"delegatedPermissions"`
Entitlements map[string][]string `json:"entitlements"`
} }
func (s *ExtendedJWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { func (s *ExtendedJWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
jwtToken := s.retrieveToken(r.HTTPRequest) jwtToken := s.retrieveAuthenticationToken(r.HTTPRequest)
claims, err := s.verifyRFC9068Token(ctx, jwtToken) claims, err := s.verifyRFC9068Token(ctx, jwtToken, rfc9068ShortMediaType)
if err != nil { if err != nil {
s.log.Error("Failed to verify JWT", "error", err) s.log.Error("Failed to verify JWT", "error", err)
return nil, errJWTInvalid.Errorf("Failed to verify JWT: %w", err) return nil, errJWTInvalid.Errorf("Failed to verify JWT: %w", err)
} }
// user:id:18 idToken := s.retrieveAuthorizationToken(r.HTTPRequest)
userID, err := strconv.ParseInt(strings.TrimPrefix(claims.Subject, fmt.Sprintf("%s:id:", authn.NamespaceUser)), 10, 64) if idToken != "" {
idTokenClaims, err := s.verifyRFC9068Token(ctx, idToken, "jwt")
if err != nil {
s.log.Error("Failed to verify id token", "error", err)
return nil, errJWTInvalid.Errorf("Failed to verify id token: %w", err)
}
return s.authenticateAsUser(idTokenClaims, claims)
}
return s.authenticateAsService(claims)
}
func (s *ExtendedJWT) authenticateAsUser(idTokenClaims,
accessTokenClaims *ExtendedJWTClaims) (*authn.Identity, error) {
// Only allow access policies to impersonate
if !strings.HasPrefix(accessTokenClaims.Subject, fmt.Sprintf("%s:", authn.NamespaceAccessPolicy)) {
s.log.Error("Invalid subject", "subject", accessTokenClaims.Subject)
return nil, errJWTInvalid.Errorf("Failed to parse sub: %s", "invalid subject format")
}
// Allow only user impersonation
_, err := strconv.ParseInt(strings.TrimPrefix(idTokenClaims.Subject, fmt.Sprintf("%s:", authn.NamespaceUser)), 10, 64)
if err != nil { if err != nil {
s.log.Error("Failed to parse sub", "error", err) s.log.Error("Failed to parse sub", "error", err)
return nil, errJWTInvalid.Errorf("Failed to parse sub: %w", err) return nil, errJWTInvalid.Errorf("Failed to parse sub: %w", err)
} }
// FIXME: support multiple organizations return &authn.Identity{
defaultOrgID := s.getDefaultOrgID() ID: idTokenClaims.Subject,
if r.OrgID != defaultOrgID { OrgID: s.getDefaultOrgID(),
s.log.Error("Failed to verify the Organization: OrgID is not the default") AuthenticatedBy: login.ExtendedJWTModule,
return nil, errJWTInvalid.Errorf("Failed to verify the Organization. Only the default org is supported") AuthID: accessTokenClaims.Subject,
ClientParams: authn.ClientParams{
SyncPermissions: true,
FetchPermissionsParams: authn.FetchPermissionsParams{
ActionsLookup: accessTokenClaims.DelegatedPermissions,
},
FetchSyncedUser: true,
}}, nil
}
func (s *ExtendedJWT) authenticateAsService(claims *ExtendedJWTClaims) (*authn.Identity, error) {
if !strings.HasPrefix(claims.Subject, fmt.Sprintf("%s:", authn.NamespaceAccessPolicy)) {
s.log.Error("Invalid subject", "subject", claims.Subject)
return nil, errJWTInvalid.Errorf("Failed to parse sub: %s", "invalid subject format")
} }
signedInUser, err := s.userService.GetSignedInUserWithCacheCtx(ctx, &user.GetSignedInUserQuery{OrgID: defaultOrgID, UserID: userID}) return &authn.Identity{
if err != nil { ID: claims.Subject,
s.log.Error("Failed to get user", "error", err) OrgID: s.getDefaultOrgID(),
return nil, errJWTInvalid.Errorf("Failed to get user: %w", err) AuthenticatedBy: login.ExtendedJWTModule,
} AuthID: claims.Subject,
ClientParams: authn.ClientParams{
if signedInUser.Permissions == nil { SyncPermissions: true,
signedInUser.Permissions = make(map[int64]map[string][]string) FetchPermissionsParams: authn.FetchPermissionsParams{
} Roles: claims.Permissions,
},
if len(claims.Entitlements) == 0 { FetchSyncedUser: false,
s.log.Error("Entitlements claim is missing") },
return nil, errJWTInvalid.Errorf("Entitlements claim is missing") }, nil
}
signedInUser.Permissions[s.getDefaultOrgID()] = claims.Entitlements
return authn.IdentityFromSignedInUser(authn.NamespacedID(authn.NamespaceUser, signedInUser.UserID), signedInUser, authn.ClientParams{SyncPermissions: false}, login.ExtendedJWTModule), nil
} }
func (s *ExtendedJWT) Test(ctx context.Context, r *authn.Request) bool { func (s *ExtendedJWT) Test(ctx context.Context, r *authn.Request) bool {
if !s.cfg.ExtendedJWTAuthEnabled { if !s.cfg.ExtJWTAuth.Enabled {
return false return false
} }
rawToken := s.retrieveToken(r.HTTPRequest) rawToken := s.retrieveAuthenticationToken(r.HTTPRequest)
if rawToken == "" { if rawToken == "" {
return false return false
} }
@ -122,7 +160,7 @@ func (s *ExtendedJWT) Test(ctx context.Context, r *authn.Request) bool {
return false return false
} }
return claims.Issuer == s.cfg.ExtendedJWTExpectIssuer return true
} }
func (s *ExtendedJWT) Name() string { func (s *ExtendedJWT) Name() string {
@ -134,16 +172,24 @@ func (s *ExtendedJWT) Priority() uint {
return 15 return 15
} }
// retrieveToken retrieves the JWT token from the request. // retrieveAuthenticationToken retrieves the JWT token from the request.
func (s *ExtendedJWT) retrieveToken(httpRequest *http.Request) string { func (s *ExtendedJWT) retrieveAuthenticationToken(httpRequest *http.Request) string {
jwtToken := httpRequest.Header.Get("Authorization") jwtToken := httpRequest.Header.Get(extJWTAuthenticationHeaderName)
// Strip the 'Bearer' prefix if it exists.
return strings.TrimPrefix(jwtToken, "Bearer ")
}
// retrieveAuthorizationToken retrieves the JWT token from the request.
func (s *ExtendedJWT) retrieveAuthorizationToken(httpRequest *http.Request) string {
jwtToken := httpRequest.Header.Get(extJWTAuthorizationHeaderName)
// Strip the 'Bearer' prefix if it exists. // Strip the 'Bearer' prefix if it exists.
return strings.TrimPrefix(jwtToken, "Bearer ") return strings.TrimPrefix(jwtToken, "Bearer ")
} }
// verifyRFC9068Token verifies the token against the RFC 9068 specification. // verifyRFC9068Token verifies the token against the RFC 9068 specification.
func (s *ExtendedJWT) verifyRFC9068Token(ctx context.Context, rawToken string) (*ExtendedJWTClaims, error) { func (s *ExtendedJWT) verifyRFC9068Token(ctx context.Context, rawToken string, typ string) (*ExtendedJWTClaims, error) {
parsedToken, err := jwt.ParseSigned(rawToken) parsedToken, err := jwt.ParseSigned(rawToken)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse JWT: %w", err) return nil, fmt.Errorf("failed to parse JWT: %w", err)
@ -161,34 +207,29 @@ func (s *ExtendedJWT) verifyRFC9068Token(ctx context.Context, rawToken string) (
} }
jwtType := strings.ToLower(typeHeader.(string)) jwtType := strings.ToLower(typeHeader.(string))
if jwtType != rfc9068ShortMediaType && jwtType != rfc9068MediaType { if !strings.EqualFold(jwtType, typ) {
return nil, fmt.Errorf("invalid JWT type: %s", jwtType) return nil, fmt.Errorf("invalid JWT type: %s", jwtType)
} }
if !slices.Contains(acceptedSigningMethods, parsedHeader.Algorithm) { if !slices.Contains(acceptedSigningMethods, parsedHeader.Algorithm) {
return nil, fmt.Errorf("invalid algorithm: %s. Accepted algorithms: %s", parsedHeader.Algorithm, strings.Join(acceptedSigningMethods, ", ")) return nil, fmt.Errorf("invalid algorithm: %s. Accepted algorithms: %s",
parsedHeader.Algorithm, strings.Join(acceptedSigningMethods, ", "))
} }
var claims ExtendedJWTClaims keyID := parsedHeader.KeyID
_, key, err := s.signingKeys.GetOrCreatePrivateKey(ctx, if keyID == "" {
signingkeys.ServerPrivateKeyID, jose.ES256) return nil, fmt.Errorf("missing 'kid' field from the header")
if err != nil {
return nil, fmt.Errorf("failed to get public key: %w", err)
} }
err = parsedToken.Claims(key.Public(), &claims) claims, err := s.verifier.Verify(ctx, rawToken)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to verify the signature: %w", err) return nil, fmt.Errorf("failed to verify JWT: %w", err)
} }
if claims.Expiry == nil { if claims.Expiry == nil {
return nil, fmt.Errorf("missing 'exp' claim") return nil, fmt.Errorf("missing 'exp' claim")
} }
if claims.ID == "" {
return nil, fmt.Errorf("missing 'jti' claim")
}
if claims.Subject == "" { if claims.Subject == "" {
return nil, fmt.Errorf("missing 'sub' claim") return nil, fmt.Errorf("missing 'sub' claim")
} }
@ -197,29 +238,7 @@ func (s *ExtendedJWT) verifyRFC9068Token(ctx context.Context, rawToken string) (
return nil, fmt.Errorf("missing 'iat' claim") return nil, fmt.Errorf("missing 'iat' claim")
} }
err = claims.ValidateWithLeeway(jwt.Expected{ return &claims.Rest, nil
Issuer: s.cfg.ExtendedJWTExpectIssuer,
Audience: jwt.Audience{s.cfg.ExtendedJWTExpectAudience},
Time: timeNow(),
}, 0)
if err != nil {
return nil, fmt.Errorf("failed to validate JWT: %w", err)
}
if err := s.validateClientIdClaim(ctx, claims); err != nil {
return nil, err
}
return &claims, nil
}
func (s *ExtendedJWT) validateClientIdClaim(ctx context.Context, claims ExtendedJWTClaims) error {
if claims.ClientID == "" {
return fmt.Errorf("missing 'client_id' claim")
}
return nil
} }
func (s *ExtendedJWT) getDefaultOrgID() int64 { func (s *ExtendedJWT) getDefaultOrgID() int64 {

View File

@ -11,11 +11,15 @@ import (
"github.com/go-jose/go-jose/v3" "github.com/go-jose/go-jose/v3"
"github.com/go-jose/go-jose/v3/jwt" "github.com/go-jose/go-jose/v3/jwt"
"golang.org/x/oauth2"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
authlib "github.com/grafana/authlib/authn"
"github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/models/usertoken"
"github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/signingkeys" "github.com/grafana/grafana/pkg/services/signingkeys"
@ -29,28 +33,45 @@ var (
validPayload = ExtendedJWTClaims{ validPayload = ExtendedJWTClaims{
Claims: jwt.Claims{ Claims: jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
Subject: "user:id:2", Subject: "access-policy:this-uid",
Audience: jwt.Audience{"http://localhost:3000"}, Audience: jwt.Audience{"http://localhost:3000"},
ID: "1234567890", ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
ClientID: "grafana", Scopes: []string{"profile", "groups"},
Scopes: []string{"profile", "groups"}, DelegatedPermissions: []string{"dashboards:create", "folders:read", "datasources:explore", "datasources.insights:read"},
Entitlements: map[string][]string{ Permissions: []string{"fixed:folders:reader"},
"dashboards:create": { }
"folders:uid:general", validIDPayload = ExtendedJWTClaims{
}, Claims: jwt.Claims{
"folders:read": { Issuer: "http://localhost:3000",
"folders:uid:general", Subject: "user:2",
}, Audience: jwt.Audience{"http://localhost:3000"},
"datasources:explore": nil, ID: "1234567890",
"datasources.insights:read": {}, Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
Scopes: []string{"profile", "groups"},
} }
pk, _ = rsa.GenerateKey(rand.Reader, 4096) pk, _ = rsa.GenerateKey(rand.Reader, 4096)
) )
type mockVerifier struct {
Claims []ExtendedJWTClaims
Error error
counter int
}
func (m *mockVerifier) Verify(ctx context.Context, token string) (*authlib.Claims[ExtendedJWTClaims], error) {
m.counter++
claims := m.Claims[m.counter-1]
return &authlib.Claims[ExtendedJWTClaims]{
Claims: &claims.Claims,
Rest: claims,
}, m.Error
}
func TestExtendedJWT_Test(t *testing.T) { func TestExtendedJWT_Test(t *testing.T) {
type testCase struct { type testCase struct {
name string name string
@ -63,7 +84,9 @@ func TestExtendedJWT_Test(t *testing.T) {
{ {
name: "should return false when extended jwt is disabled", name: "should return false when extended jwt is disabled",
cfg: &setting.Cfg{ cfg: &setting.Cfg{
ExtendedJWTAuthEnabled: false, ExtJWTAuth: setting.ExtJWTSettings{
Enabled: false,
},
}, },
authHeaderFunc: func() string { return "eyJ" }, authHeaderFunc: func() string { return "eyJ" },
want: false, want: false,
@ -71,13 +94,13 @@ func TestExtendedJWT_Test(t *testing.T) {
{ {
name: "should return true when Authorization header contains Bearer prefix", name: "should return true when Authorization header contains Bearer prefix",
cfg: nil, cfg: nil,
authHeaderFunc: func() string { return "Bearer " + generateToken(validPayload, pk, jose.RS256) }, authHeaderFunc: func() string { return "Bearer " + generateToken(validPayload, pk, jose.RS256, "at+jwt") },
want: true, want: true,
}, },
{ {
name: "should return true when Authorization header only contains the token", name: "should return true when Authorization header only contains the token",
cfg: nil, cfg: nil,
authHeaderFunc: func() string { return generateToken(validPayload, pk, jose.RS256) }, authHeaderFunc: func() string { return generateToken(validPayload, pk, jose.RS256, "at+jwt") },
want: true, want: true,
}, },
{ {
@ -95,23 +118,25 @@ func TestExtendedJWT_Test(t *testing.T) {
{ {
name: "should return false when the issuer does not match the configured issuer", name: "should return false when the issuer does not match the configured issuer",
cfg: &setting.Cfg{ cfg: &setting.Cfg{
ExtendedJWTExpectIssuer: "http://localhost:3000", ExtJWTAuth: setting.ExtJWTSettings{
ExpectIssuer: "http://localhost:3000",
},
}, },
authHeaderFunc: func() string { authHeaderFunc: func() string {
payload := validPayload payload := validPayload
payload.Issuer = "http://unknown-issuer" payload.Issuer = "http://unknown-issuer"
return generateToken(payload, pk, jose.RS256) return generateToken(payload, pk, jose.RS256, "at+jwt")
}, },
want: false, want: false,
}, },
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
env := setupTestCtx(t, tc.cfg) env := setupTestCtx(tc.cfg)
validHTTPReq := &http.Request{ validHTTPReq := &http.Request{
Header: map[string][]string{ Header: map[string][]string{
"Authorization": {tc.authHeaderFunc()}, "X-Access-Token": {tc.authHeaderFunc()},
}, },
} }
@ -129,16 +154,39 @@ func TestExtendedJWT_Authenticate(t *testing.T) {
type testCase struct { type testCase struct {
name string name string
payload ExtendedJWTClaims payload ExtendedJWTClaims
idPayload *ExtendedJWTClaims
orgID int64 orgID int64
want *authn.Identity want *authn.Identity
initTestEnv func(env *testEnv) initTestEnv func(env *testEnv)
wantErr bool wantErr error
} }
testCases := []testCase{ testCases := []testCase{
{ {
name: "successful authentication", name: "successful authentication as service",
payload: validPayload, payload: validPayload,
orgID: 1, orgID: 1,
want: &authn.Identity{OrgID: 1, OrgName: "",
OrgRoles: map[int64]roletype.RoleType(nil),
ID: "access-policy:this-uid", Login: "", Name: "", Email: "",
IsGrafanaAdmin: (*bool)(nil), AuthenticatedBy: "extendedjwt",
AuthID: "access-policy:this-uid", IsDisabled: false, HelpFlags1: 0x0,
LastSeenAt: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
Teams: []int64(nil), Groups: []string(nil),
OAuthToken: (*oauth2.Token)(nil), SessionToken: (*usertoken.UserToken)(nil),
ClientParams: authn.ClientParams{SyncUser: false,
AllowSignUp: false, EnableUser: false, FetchSyncedUser: false,
SyncTeams: false, SyncOrgRoles: false, CacheAuthProxyKey: "",
LookUpParams: login.UserLookupParams{UserID: (*int64)(nil),
Email: (*string)(nil), Login: (*string)(nil)}, SyncPermissions: true,
FetchPermissionsParams: authn.FetchPermissionsParams{ActionsLookup: []string(nil), Roles: []string{"fixed:folders:reader"}}},
Permissions: map[int64]map[string][]string(nil), IDToken: ""},
wantErr: nil,
},
{
name: "successful authentication as user",
payload: validPayload,
idPayload: &validIDPayload,
orgID: 1,
initTestEnv: func(env *testEnv) { initTestEnv: func(env *testEnv) {
env.userSvc.ExpectedSignedInUser = &user.SignedInUser{ env.userSvc.ExpectedSignedInUser = &user.SignedInUser{
UserID: 2, UserID: 2,
@ -149,50 +197,26 @@ func TestExtendedJWT_Authenticate(t *testing.T) {
Login: "johndoe", Login: "johndoe",
} }
}, },
want: &authn.Identity{ want: &authn.Identity{OrgID: 1, OrgName: "",
OrgID: 1, OrgRoles: map[int64]roletype.RoleType(nil), ID: "user:2",
OrgName: "", Login: "", Name: "", Email: "",
OrgRoles: map[int64]roletype.RoleType{1: roletype.RoleAdmin}, IsGrafanaAdmin: (*bool)(nil), AuthenticatedBy: "extendedjwt",
ID: "user:2", AuthID: "access-policy:this-uid", IsDisabled: false, HelpFlags1: 0x0,
Login: "johndoe", LastSeenAt: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
Name: "John Doe", Teams: []int64(nil), Groups: []string(nil),
Email: "johndoe@grafana.com", OAuthToken: (*oauth2.Token)(nil), SessionToken: (*usertoken.UserToken)(nil),
IsGrafanaAdmin: boolPtr(false), ClientParams: authn.ClientParams{SyncUser: false, AllowSignUp: false,
AuthenticatedBy: login.ExtendedJWTModule, EnableUser: false, FetchSyncedUser: true, SyncTeams: false,
AuthID: "", SyncOrgRoles: false, CacheAuthProxyKey: "",
IsDisabled: false, LookUpParams: login.UserLookupParams{UserID: (*int64)(nil), Email: (*string)(nil), Login: (*string)(nil)},
HelpFlags1: 0, SyncPermissions: true,
Permissions: map[int64]map[string][]string{ FetchPermissionsParams: authn.FetchPermissionsParams{ActionsLookup: []string{"dashboards:create",
1: { "folders:read", "datasources:explore", "datasources.insights:read"},
"dashboards:create": { Roles: []string(nil)}}, Permissions: map[int64]map[string][]string(nil), IDToken: ""},
"folders:uid:general", wantErr: nil,
},
"folders:read": {
"folders:uid:general",
},
"datasources:explore": nil,
"datasources.insights:read": []string{},
},
},
ClientParams: authn.ClientParams{
SyncUser: false,
AllowSignUp: false,
FetchSyncedUser: false,
EnableUser: false,
SyncOrgRoles: false,
SyncTeams: false,
SyncPermissions: false,
LookUpParams: login.UserLookupParams{
UserID: nil,
Email: nil,
Login: nil,
},
},
},
wantErr: false,
}, },
{ {
name: "should return error when the user cannot be parsed from the Subject claim", name: "should return error when the subject is not an access-policy",
payload: ExtendedJWTClaims{ payload: ExtendedJWTClaims{
Claims: jwt.Claims{ Claims: jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
@ -202,94 +226,40 @@ func TestExtendedJWT_Authenticate(t *testing.T) {
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
ClientID: "grafana", Permissions: []string{"fixed:folders:reader"},
Scopes: []string{"profile", "groups"},
}, },
orgID: 1, orgID: 1,
want: nil, want: nil,
wantErr: true, wantErr: errJWTInvalid.Errorf("Failed to parse sub: %s", "invalid subject format"),
},
{
name: "should return error when the OrgId is not the ID of the default org",
payload: ExtendedJWTClaims{
Claims: jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "user:id:2",
Audience: jwt.Audience{"http://localhost:3000"},
ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
},
ClientID: "grafana",
Scopes: []string{"profile", "groups"},
},
orgID: 0,
want: nil,
wantErr: true,
},
{
name: "should return error when the user cannot be found",
payload: ExtendedJWTClaims{
Claims: jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "user:id:2",
Audience: jwt.Audience{"http://localhost:3000"},
ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
},
ClientID: "grafana",
Scopes: []string{"profile", "groups"},
},
orgID: 1,
want: nil,
initTestEnv: func(env *testEnv) {
env.userSvc.ExpectedError = user.ErrUserNotFound
},
wantErr: true,
},
{
name: "should return error when entitlements claim is missing",
payload: ExtendedJWTClaims{
Claims: jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "user:id:2",
Audience: jwt.Audience{"http://localhost:3000"},
ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
},
ClientID: "grafana",
Scopes: []string{"profile", "groups"},
},
orgID: 1,
want: nil,
wantErr: true,
}, },
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
env := setupTestCtx(t, nil) env := setupTestCtx(nil)
if tc.initTestEnv != nil { if tc.initTestEnv != nil {
tc.initTestEnv(env) tc.initTestEnv(env)
} }
validHTTPReq := &http.Request{ validHTTPReq := &http.Request{
Header: map[string][]string{ Header: map[string][]string{
"Authorization": {generateToken(tc.payload, pk, jose.RS256)}, "X-Access-Token": {generateToken(tc.payload, pk, jose.RS256, "at+jwt")},
}, },
} }
mockTimeNow(time.Date(2023, 5, 2, 0, 1, 0, 0, time.UTC)) env.s.verifier = &mockVerifier{Claims: []ExtendedJWTClaims{tc.payload}}
if tc.idPayload != nil {
env.s.verifier = &mockVerifier{Claims: []ExtendedJWTClaims{tc.payload, *tc.idPayload}}
validHTTPReq.Header.Add(extJWTAuthorizationHeaderName, generateToken(*tc.idPayload, pk, jose.RS256, "jwt"))
}
id, err := env.s.Authenticate(context.Background(), &authn.Request{ id, err := env.s.Authenticate(context.Background(), &authn.Request{
OrgID: tc.orgID, OrgID: tc.orgID,
HTTPRequest: validHTTPReq, HTTPRequest: validHTTPReq,
Resp: nil, Resp: nil,
}) })
if tc.wantErr { if tc.wantErr != nil {
require.Error(t, err) require.ErrorIs(t, err, tc.wantErr)
} else { } else {
require.NoError(t, err) require.NoError(t, err)
assert.EqualValues(t, tc.want, id, fmt.Sprintf("%+v", id)) assert.EqualValues(t, tc.want, id, fmt.Sprintf("%+v", id))
@ -304,6 +274,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
name string name string
payload ExtendedJWTClaims payload ExtendedJWTClaims
alg jose.SignatureAlgorithm alg jose.SignatureAlgorithm
typ string
} }
testCases := []testCase{ testCases := []testCase{
@ -311,14 +282,13 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
name: "missing iss", name: "missing iss",
payload: ExtendedJWTClaims{ payload: ExtendedJWTClaims{
Claims: jwt.Claims{ Claims: jwt.Claims{
Subject: "user:id:2", Subject: "access-policy:this-uid",
Audience: jwt.Audience{"http://localhost:3000"}, Audience: jwt.Audience{"http://localhost:3000"},
ID: "1234567890", ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
ClientID: "grafana", Scopes: []string{"profile", "groups"},
Scopes: []string{"profile", "groups"},
}, },
}, },
{ {
@ -326,13 +296,12 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
payload: ExtendedJWTClaims{ payload: ExtendedJWTClaims{
Claims: jwt.Claims{ Claims: jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
Subject: "user:id:2", Subject: "access-policy:this-uid",
Audience: jwt.Audience{"http://localhost:3000"}, Audience: jwt.Audience{"http://localhost:3000"},
ID: "1234567890", ID: "1234567890",
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
ClientID: "grafana", Scopes: []string{"profile", "groups"},
Scopes: []string{"profile", "groups"},
}, },
}, },
{ {
@ -340,14 +309,13 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
payload: ExtendedJWTClaims{ payload: ExtendedJWTClaims{
Claims: jwt.Claims{ Claims: jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
Subject: "user:id:2", Subject: "access-policy:this-uid",
Audience: jwt.Audience{"http://localhost:3000"}, Audience: jwt.Audience{"http://localhost:3000"},
ID: "1234567890", ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), Expiry: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
ClientID: "grafana", Scopes: []string{"profile", "groups"},
Scopes: []string{"profile", "groups"},
}, },
}, },
{ {
@ -355,13 +323,12 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
payload: ExtendedJWTClaims{ payload: ExtendedJWTClaims{
Claims: jwt.Claims{ Claims: jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
Subject: "user:id:2", Subject: "access-policy:this-uid",
ID: "1234567890", ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
ClientID: "grafana", Scopes: []string{"profile", "groups"},
Scopes: []string{"profile", "groups"},
}, },
}, },
{ {
@ -369,16 +336,30 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
payload: ExtendedJWTClaims{ payload: ExtendedJWTClaims{
Claims: jwt.Claims{ Claims: jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
Subject: "user:id:2", Subject: "access-policy:this-uid",
Audience: jwt.Audience{"http://some-other-host:3000"}, Audience: jwt.Audience{"http://some-other-host:3000"},
ID: "1234567890", ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
ClientID: "grafana", Scopes: []string{"profile", "groups"},
Scopes: []string{"profile", "groups"},
}, },
}, },
{
name: "wrong typ",
payload: ExtendedJWTClaims{
Claims: jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "access-policy:this-uid",
Audience: jwt.Audience{"http://some-other-host:3000"},
ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
},
Scopes: []string{"profile", "groups"},
},
typ: "jwt",
},
{ {
name: "missing sub", name: "missing sub",
payload: ExtendedJWTClaims{ payload: ExtendedJWTClaims{
@ -389,21 +370,6 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
ClientID: "grafana",
Scopes: []string{"profile", "groups"},
},
},
{
name: "missing client_id",
payload: ExtendedJWTClaims{
Claims: jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "user:id:2",
Audience: jwt.Audience{"http://localhost:3000"},
ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
},
Scopes: []string{"profile", "groups"}, Scopes: []string{"profile", "groups"},
}, },
}, },
@ -412,13 +378,12 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
payload: ExtendedJWTClaims{ payload: ExtendedJWTClaims{
Claims: jwt.Claims{ Claims: jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
Subject: "user:id:2", Subject: "access-policy:this-uid",
Audience: jwt.Audience{"http://localhost:3000"}, Audience: jwt.Audience{"http://localhost:3000"},
ID: "1234567890", ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
}, },
ClientID: "grafana", Scopes: []string{"profile", "groups"},
Scopes: []string{"profile", "groups"},
}, },
}, },
{ {
@ -426,28 +391,13 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
payload: ExtendedJWTClaims{ payload: ExtendedJWTClaims{
Claims: jwt.Claims{ Claims: jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
Subject: "user:id:2", Subject: "access-policy:this-uid",
Audience: jwt.Audience{"http://localhost:3000"}, Audience: jwt.Audience{"http://localhost:3000"},
ID: "1234567890", ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 2, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 2, 0, 0, time.UTC)),
}, },
ClientID: "grafana", Scopes: []string{"profile", "groups"},
Scopes: []string{"profile", "groups"},
},
},
{
name: "missing jti",
payload: ExtendedJWTClaims{
Claims: jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "user:id:2",
Audience: jwt.Audience{"http://localhost:3000"},
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
},
ClientID: "grafana",
Scopes: []string{"profile", "groups"},
}, },
}, },
{ {
@ -455,40 +405,40 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
payload: ExtendedJWTClaims{ payload: ExtendedJWTClaims{
Claims: jwt.Claims{ Claims: jwt.Claims{
Issuer: "http://localhost:3000", Issuer: "http://localhost:3000",
Subject: "user:id:2", Subject: "access-policy:this-uid",
Audience: jwt.Audience{"http://localhost:3000"}, Audience: jwt.Audience{"http://localhost:3000"},
ID: "1234567890", ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
}, },
ClientID: "grafana", Scopes: []string{"profile", "groups"},
Scopes: []string{"profile", "groups"},
}, },
alg: jose.RS384, alg: jose.RS384,
}, },
} }
env := setupTestCtx(t, nil) env := setupTestCtx(nil)
mockTimeNow(time.Date(2023, 5, 2, 0, 1, 0, 0, time.UTC))
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
if tc.alg == "" { if tc.alg == "" {
tc.alg = jose.RS256 tc.alg = jose.RS256
} }
tokenToTest := generateToken(tc.payload, pk, tc.alg) tokenToTest := generateToken(tc.payload, pk, tc.alg, "at+jwt")
_, err := env.s.verifyRFC9068Token(context.Background(), tokenToTest) _, err := env.s.verifyRFC9068Token(context.Background(), tokenToTest, rfc9068ShortMediaType)
require.Error(t, err) require.Error(t, err)
}) })
} }
} }
func setupTestCtx(t *testing.T, cfg *setting.Cfg) *testEnv { func setupTestCtx(cfg *setting.Cfg) *testEnv {
if cfg == nil { if cfg == nil {
cfg = &setting.Cfg{ cfg = &setting.Cfg{
ExtendedJWTAuthEnabled: true, ExtJWTAuth: setting.ExtJWTSettings{
ExtendedJWTExpectIssuer: "http://localhost:3000", Enabled: true,
ExtendedJWTExpectAudience: "http://localhost:3000", ExpectIssuer: "http://localhost:3000",
ExpectAudience: "http://localhost:3000",
},
} }
} }
@ -512,18 +462,13 @@ type testEnv struct {
s *ExtendedJWT s *ExtendedJWT
} }
func generateToken(payload ExtendedJWTClaims, signingKey any, alg jose.SignatureAlgorithm) string { func generateToken(payload ExtendedJWTClaims, signingKey any, alg jose.SignatureAlgorithm, typ string) string {
signer, _ := jose.NewSigner(jose.SigningKey{Algorithm: alg, Key: signingKey}, &jose.SignerOptions{ signer, _ := jose.NewSigner(jose.SigningKey{Algorithm: alg, Key: signingKey}, &jose.SignerOptions{
ExtraHeaders: map[jose.HeaderKey]any{ ExtraHeaders: map[jose.HeaderKey]any{
jose.HeaderType: "at+jwt", jose.HeaderType: typ,
"kid": "default",
}}) }})
result, _ := jwt.Signed(signer).Claims(payload).CompactSerialize() result, _ := jwt.Signed(signer).Claims(payload).CompactSerialize()
return result return result
} }
func mockTimeNow(timeSeed time.Time) {
timeNow = func() time.Time {
return timeSeed
}
}

View File

@ -27,6 +27,7 @@ const (
NamespaceServiceAccount = identity.NamespaceServiceAccount NamespaceServiceAccount = identity.NamespaceServiceAccount
NamespaceAnonymous = identity.NamespaceAnonymous NamespaceAnonymous = identity.NamespaceAnonymous
NamespaceRenderService = identity.NamespaceRenderService NamespaceRenderService = identity.NamespaceRenderService
NamespaceAccessPolicy = identity.NamespaceAccessPolicy
) )
const ( const (
@ -230,6 +231,7 @@ func (i *Identity) SignedInUser() *user.SignedInUser {
Teams: i.Teams, Teams: i.Teams,
Permissions: i.Permissions, Permissions: i.Permissions,
IDToken: i.IDToken, IDToken: i.IDToken,
NamespacedID: i.ID,
} }
if namespace == NamespaceAPIKey { if namespace == NamespaceAPIKey {

View File

@ -5,9 +5,10 @@ import (
"strconv" "strconv"
"testing" "testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/stretchr/testify/require"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {

View File

@ -1155,6 +1155,14 @@ var (
Owner: grafanaFrontendPlatformSquad, Owner: grafanaFrontendPlatformSquad,
Expression: "true", // enabled by default Expression: "true", // enabled by default
}, },
{
Name: "authAPIAccessTokenAuth",
Description: "Enables the use of Auth API access tokens for authentication",
Stage: FeatureStageExperimental,
Owner: identityAccessTeam,
HideFromDocs: true,
HideFromAdminPage: true,
},
{ {
Name: "scopeFilters", Name: "scopeFilters",
Description: "Enables the use of scope filters in Grafana", Description: "Enables the use of scope filters in Grafana",

View File

@ -154,6 +154,7 @@ kubernetesAggregator,experimental,@grafana/grafana-app-platform-squad,false,true
expressionParser,experimental,@grafana/grafana-app-platform-squad,false,true,false expressionParser,experimental,@grafana/grafana-app-platform-squad,false,true,false
groupByVariable,experimental,@grafana/dashboards-squad,false,false,false groupByVariable,experimental,@grafana/dashboards-squad,false,false,false
betterPageScrolling,GA,@grafana/grafana-frontend-platform,false,false,true betterPageScrolling,GA,@grafana/grafana-frontend-platform,false,false,true
authAPIAccessTokenAuth,experimental,@grafana/identity-access-team,false,false,false
scopeFilters,experimental,@grafana/dashboards-squad,false,false,false scopeFilters,experimental,@grafana/dashboards-squad,false,false,false
ssoSettingsSAML,experimental,@grafana/identity-access-team,false,false,false ssoSettingsSAML,experimental,@grafana/identity-access-team,false,false,false
usePrometheusFrontendPackage,experimental,@grafana/observability-metrics,false,false,true usePrometheusFrontendPackage,experimental,@grafana/observability-metrics,false,false,true

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
154 expressionParser experimental @grafana/grafana-app-platform-squad false true false
155 groupByVariable experimental @grafana/dashboards-squad false false false
156 betterPageScrolling GA @grafana/grafana-frontend-platform false false true
157 authAPIAccessTokenAuth experimental @grafana/identity-access-team false false false
158 scopeFilters experimental @grafana/dashboards-squad false false false
159 ssoSettingsSAML experimental @grafana/identity-access-team false false false
160 usePrometheusFrontendPackage experimental @grafana/observability-metrics false false true

View File

@ -627,6 +627,10 @@ const (
// Removes CustomScrollbar from the UI, relying on native browser scrollbars // Removes CustomScrollbar from the UI, relying on native browser scrollbars
FlagBetterPageScrolling = "betterPageScrolling" FlagBetterPageScrolling = "betterPageScrolling"
// FlagAuthAPIAccessTokenAuth
// Enables the use of Auth API access tokens for authentication
FlagAuthAPIAccessTokenAuth = "authAPIAccessTokenAuth"
// FlagScopeFilters // FlagScopeFilters
// Enables the use of scope filters in Grafana // Enables the use of scope filters in Grafana
FlagScopeFilters = "scopeFilters" FlagScopeFilters = "scopeFilters"

View File

@ -2063,6 +2063,20 @@
"hideFromAdminPage": true, "hideFromAdminPage": true,
"hideFromDocs": true "hideFromDocs": true
} }
},
{
"metadata": {
"name": "authAPIAccessTokenAuth",
"resourceVersion": "1711701535283",
"creationTimestamp": "2024-03-29T08:38:55Z"
},
"spec": {
"description": "Enables the use of Auth API access tokens for authentication",
"stage": "experimental",
"codeowner": "@grafana/identity-access-team",
"hideFromAdminPage": true,
"hideFromDocs": true
}
} }
] ]
} }

View File

@ -35,7 +35,8 @@ type SignedInUser struct {
Permissions map[int64]map[string][]string `json:"-"` Permissions map[int64]map[string][]string `json:"-"`
// IDToken is a signed token representing the identity that can be forwarded to plugins and external services. // IDToken is a signed token representing the identity that can be forwarded to plugins and external services.
// Will only be set when featuremgmt.FlagIdForwarding is enabled. // Will only be set when featuremgmt.FlagIdForwarding is enabled.
IDToken string `json:"-" xorm:"-"` IDToken string `json:"-" xorm:"-"`
NamespacedID string
} }
func (u *SignedInUser) ShouldUpdateLastSeenAt() bool { func (u *SignedInUser) ShouldUpdateLastSeenAt() bool {
@ -205,8 +206,7 @@ func (u *SignedInUser) GetID() string {
return namespacedID(identity.NamespaceRenderService, 0) return namespacedID(identity.NamespaceRenderService, 0)
} }
// backwards compatibility return u.NamespacedID
return namespacedID(identity.NamespaceUser, u.UserID)
} }
// GetNamespacedID returns the namespace and ID of the active entity // GetNamespacedID returns the namespace and ID of the active entity
@ -214,6 +214,10 @@ func (u *SignedInUser) GetID() string {
func (u *SignedInUser) GetNamespacedID() (string, string) { func (u *SignedInUser) GetNamespacedID() (string, string) {
parts := strings.Split(u.GetID(), ":") parts := strings.Split(u.GetID(), ":")
// Safety: GetID always returns a ':' separated string // Safety: GetID always returns a ':' separated string
if len(parts) != 2 {
return "", ""
}
return parts[0], parts[1] return parts[0], parts[1]
} }

View File

@ -261,11 +261,8 @@ type Cfg struct {
OAuthCookieMaxAge int OAuthCookieMaxAge int
OAuthAllowInsecureEmailLookup bool OAuthAllowInsecureEmailLookup bool
JWTAuth AuthJWTSettings JWTAuth AuthJWTSettings
// Extended JWT Auth ExtJWTAuth ExtJWTSettings
ExtendedJWTAuthEnabled bool
ExtendedJWTExpectIssuer string
ExtendedJWTExpectAudience string
// SSO Settings Auth // SSO Settings Auth
SSOSettingsReloadInterval time.Duration SSOSettingsReloadInterval time.Duration
@ -1186,6 +1183,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error {
cfg.handleAWSConfig() cfg.handleAWSConfig()
cfg.readAzureSettings() cfg.readAzureSettings()
cfg.readAuthJWTSettings() cfg.readAuthJWTSettings()
cfg.readAuthExtJWTSettings()
cfg.readAuthProxySettings() cfg.readAuthProxySettings()
cfg.readSessionConfig() cfg.readSessionConfig()
if err := cfg.readSmtpSettings(); err != nil { if err := cfg.readSmtpSettings(); err != nil {
@ -1602,12 +1600,6 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) {
cfg.BasicAuthEnabled = authBasic.Key("enabled").MustBool(true) cfg.BasicAuthEnabled = authBasic.Key("enabled").MustBool(true)
cfg.BasicAuthStrongPasswordPolicy = authBasic.Key("password_policy").MustBool(false) cfg.BasicAuthStrongPasswordPolicy = authBasic.Key("password_policy").MustBool(false)
// Extended JWT auth
authExtendedJWT := cfg.SectionWithEnvOverrides("auth.extended_jwt")
cfg.ExtendedJWTAuthEnabled = authExtendedJWT.Key("enabled").MustBool(false)
cfg.ExtendedJWTExpectAudience = authExtendedJWT.Key("expect_audience").MustString("")
cfg.ExtendedJWTExpectIssuer = authExtendedJWT.Key("expect_issuer").MustString("")
// SSO Settings // SSO Settings
ssoSettings := iniFile.Section("sso_settings") ssoSettings := iniFile.Section("sso_settings")
cfg.SSOSettingsReloadInterval = ssoSettings.Key("reload_interval").MustDuration(1 * time.Minute) cfg.SSOSettingsReloadInterval = ssoSettings.Key("reload_interval").MustDuration(1 * time.Minute)

View File

@ -25,6 +25,22 @@ type AuthJWTSettings struct {
UsernameAttributePath string UsernameAttributePath string
} }
type ExtJWTSettings struct {
Enabled bool
ExpectIssuer string
ExpectAudience string
JWKSUrl string
}
func (cfg *Cfg) readAuthExtJWTSettings() {
authExtendedJWT := cfg.SectionWithEnvOverrides("auth.extended_jwt")
jwtSettings := ExtJWTSettings{}
jwtSettings.Enabled = authExtendedJWT.Key("enabled").MustBool(false)
jwtSettings.ExpectAudience = authExtendedJWT.Key("expect_audience").MustString("")
jwtSettings.JWKSUrl = authExtendedJWT.Key("jwks_url").MustString("")
cfg.ExtJWTAuth = jwtSettings
}
func (cfg *Cfg) readAuthJWTSettings() { func (cfg *Cfg) readAuthJWTSettings() {
jwtSettings := AuthJWTSettings{} jwtSettings := AuthJWTSettings{}
authJWT := cfg.Raw.Section("auth.jwt") authJWT := cfg.Raw.Section("auth.jwt")

View File

@ -174,7 +174,7 @@ func TestApplyUserHeader(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
req.Header.Set("X-Grafana-User", "admin") req.Header.Set("X-Grafana-User", "admin")
ApplyUserHeader(false, req, &user.SignedInUser{Login: "admin"}) ApplyUserHeader(false, req, &user.SignedInUser{Login: "admin", NamespacedID: "user:1"})
require.NotContains(t, req.Header, "X-Grafana-User") require.NotContains(t, req.Header, "X-Grafana-User")
}) })
@ -191,7 +191,7 @@ func TestApplyUserHeader(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", nil) req, err := http.NewRequest(http.MethodGet, "/", nil)
require.NoError(t, err) require.NoError(t, err)
ApplyUserHeader(true, req, &user.SignedInUser{IsAnonymous: true}) ApplyUserHeader(true, req, &user.SignedInUser{IsAnonymous: true, NamespacedID: "anonymous:1"})
require.NotContains(t, req.Header, "X-Grafana-User") require.NotContains(t, req.Header, "X-Grafana-User")
}) })
@ -199,7 +199,7 @@ func TestApplyUserHeader(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", nil) req, err := http.NewRequest(http.MethodGet, "/", nil)
require.NoError(t, err) require.NoError(t, err)
ApplyUserHeader(true, req, &user.SignedInUser{Login: "admin"}) ApplyUserHeader(true, req, &user.SignedInUser{Login: "admin", NamespacedID: "user:1"})
require.Equal(t, "admin", req.Header.Get("X-Grafana-User")) require.Equal(t, "admin", req.Header.Get("X-Grafana-User"))
}) })
} }