Authorisation is broken down into two parts:
- JWT token parsing: read the
Authorization
header of a request, and parse the JWT token contained in it. From the JWT token the user ID and list of groups the user belongs to is extracted and returned in theEntityData
type. This functionality is within thejwt
package. See the package readme for more details - Permissions check - the action that the user is taking will have a permission associated with it. The permissions check does a lookup to see if the requested permission is granted to the user, or the groups that the user belongs to. This functionality is within the
permissions
package. See the package readme for more details
The permission check will typically be wrapped around an entire endpoint via middleware, but it can also be checked within a handler with more complex logic if needed.
The config values for authorisation are the same regardless of how authorisation is applied to a service. The authorisation package provides a configuration type that can be embedded within an existing service config type.
type Config struct {
...
AuthorisationConfig *authorisation.Config
}
A set of default configuration values can be retrieved using the authorisation.NewDefaultConfig()
function. These can be used for local development and testing. The config values should be set as environment variables when running in an environment.
In order to verify a JWT's validity, the RSA public signing keys used to sign the JWT generated by the AWS Cognito User Pool are required. There are 2 RSA public signing keys associated with a User Pool. The Key ID (KID) header in the JWT is used to determine which of these keys has been used to sign the JWT. The map is of the pointer form *map[string]string
.
When consuming this library from a service you have 2 options:
- supply the map manually, or
- allow the library to obtain the keys and set the map automatically for you
- for this, when creating a new instance, simply set the third argument to
nil
- for this, when creating a new instance, simply set the third argument to
For the typical case of adding authorisation as middleware, the JWT parsing and permissions checking has been bundled into a single Middleware
type.
-
Option 1 - set RSA Public Signing key map automatically:
authorisationMiddleware, err := authorisation.NewFeatureFlaggedMiddleware(ctx, authorisationConfig, nil)
-
Option 2 - set RSA Public Signing key map manually:
authorisationMiddleware, err := authorisation.NewFeatureFlaggedMiddleware(ctx, authorisationConfig, &jwtRSAPublicSigningKeyMap)
where
jwtRSAPublicSigningKeyMap
has the form (may come from aconfig
for example):{ "GHB723n83jw=": "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0TpTemKodQNChMNj1f/NF19nM", "HUJB8hw29js=": "MIICIjANBgkqBUHJHUJOIOIJIOH&*B(IHUGYCgKCAgEA0TpTemKodQNChMNj1f/NF19nM" }
Using the NewFeatureFlaggedMiddleware
constructor will use the Enabled
config value to automatically apply a feature flag to authorisation. If the flag is disabled, a no-op instance of middleware will be used. This minimises the amount of code required to apply a feature flag to authorisation. Endpoints can still be wrapped with the authorisation middleware, but it will just act as a pass-through if authorisation is disabled. Should you want to create a middleware instance without a feature flag, use the NewMiddlewareFromConfig
constructor function instead.
r.HandleFunc("/v1/users", authorisationMiddleware.Require("users:create", api.CreateUserHandler)).Methods(http.MethodPost)
The above example shows the POST /v1/users
endpoint being wrapped with authorisation middleware, requiring the caller to have the users:create
permission.
if err := hc.AddCheck("permissions cache health check", authorisationMiddleware.HealthCheck); err != nil {
hasErrors = true
log.Error(ctx, "error adding check for permissions cache", err)
}
if err := hc.AddCheck("identity client jwt keys health check", authorisationMiddleware.IdentityHealthCheck); err != nil {
hasErrors = true
log.Error(ctx, "identity client jwt keys health check", err)
}
if err := svc.authorisationMiddleware.Close(ctx); err != nil {
log.Error(ctx, "failed to close authorisation middleware", err)
hasShutdownError = true
}
A mock for the Middleware
interface is available for unit testing:
import (
authorisation "github.com/ONSdigital/dp-authorisation/v2/authorisation/mock"
)
...
middlewareMock := &authorisation.MiddlewareMock{
RequireFunc: func(permission string, handlerFunc http.HandlerFunc) http.HandlerFunc {
return handlerFunc
},
}
A mock for the ZebedeeClient
interface is available for unit testing:
import (
authorisation "github.com/ONSdigital/dp-authorisation/v2/authorisation/mock"
)
...
zebedeeIdentity = &mock.ZebedeeClientMock{
CheckTokenIdentityFunc: func(ctx context.Context, token string) (*dprequest.IdentityResponse, error) {
return &dprequest.IdentityResponse{
Identifier: "[email protected]",
}, nil
},
}
If the authorisation for a service requires something more complex than middleware around a handler, the implementation will depend on the service's particular requirements. Though it will still come down to the two fundamental pieces of the authorisation - JWT token parsing, and permissions checking. Refer to the readme's for the JWT parser and permissions checker for more information on creating and using them.
It should also be considered how a feature flag may be applied in this case. The authorisation config type contains an Enabled
boolean for this purpose, but usage of the flag will need to be implemented.
The JWT token parsing could potentially be done within middleware, and the EntityData that comes from the JWT could be stored in the request content for later use within the handler. Other than that the JWT parser could be used directly within the handler.
Once the JWT token is parsed into EntityData, it can be passed to the permissions checker to determine if the user has access. It's likely at this point that additional data will be needed by the permissions checker to make a decision. This is where the attributes
parameter of the permissions checker is used - for example to set a collection ID:
permission := "legacy.read"
attributes := map[string]string{"collection_id": "collection123"}
hasPermission, err := permissionChecker.HasPermission(ctx, entityData, permission, attributes)
Mock types for the JWTParser
and PermissionsChecker
interfaces are available under the github.com/ONSdigital/dp-authorisation/v2/authorisation/mock
import path.
The authorisationtest
package provides test JWT tokens, and a fake permissions API that can be used in component tests.
Instantiate the fake permissions API in the test component, then read the URL value to set the permissions API URL in the config:
fakePermissionsAPI := authorisationtest.NewFakePermissionsAPI()
c.Config.AuthorisationConfig.PermissionsAPIURL = fakePermissionsAPI.URL()
Once the config value is set for the permissions API, use the authorisation code (middleware or permissions checker) as it is used in the service.
The JWT tokens provided emulate users who are member of different groups. They have been generated to work with the public key that's provided in the default configuration.
To use the test JWT tokens within a component test, register a step that adds the token as a header (example taken from the Identity API):
import (
"github.com/ONSdigital/dp-authorisation/v2/authorisationtest"
)
...
ctx.Step(`^I am an admin user$`, c.adminJWTToken)
...
func (c *IdentityComponent) adminJWTToken() error {
err := c.apiFeature.ISetTheHeaderTo(api.AccessTokenHeaderName, authorisationtest.AdminJWTToken)
return err
}
Then the JWT token can be added to a request in the feature file:
Given I am an admin user
When ...
The following example demonstrates how to retrieve the Parsed token EntityData from the authorisation middleware instance dynamically, i.e without specifying the RSA public signing keys.
// main.go
package main
import (
"context"
"fmt"
"github.com/ONSdigital/dp-authorisation/v2/authorisation"
)
func main() {
// the following retrieves auth config values used in local dev and testing. For other environments, ensure you set IDENTITY_WEB_KEY_SET_URL env variable – https://github.com/ONSdigital/dp-authorisation/blob/master/v2/authorisation/config.go#L5-L15
cfg := authorisation.NewDefaultConfig()
cfg.JWTVerificationPublicKeys = nil
ctx := context.Background()
authorisationMiddleware, err := authorisation.NewFeatureFlaggedMiddleware(ctx, cfg, nil)
if err != nil {
fmt.Println(err)
}
// NOTE: If you retrieve the token from florence, ensure to strip out the `Bearer` string preceding the token.
token := "eyJraWQiOiIyYTh2WG1JSzY3WlozaEZaXC9Ed1FBVGd2cVpnUkJGanV1VmF2bHczekV3bz0iLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJiOTIyM2VjZi0wYzMxLTRmZWUtODVkOC0zZmJlNjAwM2M1MWQiLCJjb2duaXRvOmdyb3VwcyI6WyJhZmU5ODA0OS0wNzU4LTRiYTgtYmQwNy1mOTY4ZjllYmFkMWQiXSwiaXNzIjoiaHR0cHM6XC9cL2NvZ25pdG8taWRwLmV1LXdlc3QtMS5hbWF6b25hd3MuY29tXC9ldS13ZXN0LTFfUm5tYTlscDJxIiwiY2xpZW50X2lkIjoiZGZjbTRvbms2MHJtc3NhOHJuN3NoamtpdiIsIm9yaWdpbl9qdGkiOiJlMGYzMjdiYS02MzA0LTQ0MzEtYjZmMy1jNTUwZTgwZTllYmIiLCJldmVudF9pZCI6IjYyZmM3Y2NjLWM0MTgtNGFmNC1hMDhlLThlZmU2NDU5MWUwNSIsInRva2VuX3VzZSI6ImFjY2VzcyIsInNjb3BlIjoiYXdzLmNvZ25pdG8uc2lnbmluLnVzZXIuYWRtaW4iLCJhdXRoX3RpbWUiOjE2NTY1MDE3NTUsImV4cCI6MTY1NjUwNTM1NSwiaWF0IjoxNjU2NTAxNzU1LCJqdGkiOiI0MzQ5OTc2Zi1kMjIyLTQwNGQtYTJkMy0zNTM4NjRjZGVjOWYiLCJ1c2VybmFtZSI6IjFjZjFlMDI1LTJhZDYtNGQ1NC04NmRiLTEzYTlhMjcxMTg1OCJ9.F6w7yuEh-tThF8Q_qH7oOwq5wNSvhDLltCKVTEHvyOa15CsMBepoAOu3XW6xHO-S6z60I17t3u4KGCI6iOsPclo7nGQsoq0bpxsgMAoPjZhOCk7qzDjbHBvk_MA2NLR8tDbxwfdlDiCQviKK3rLj6xT_n9jdcGhDrf58AO2gNNHxrGIg83iWhG650OS0AdGtc1rcVudlNoIpbwKOk1cLtfj44jozc4ZWI34MgGuz5bFtCJ39ZPAJuA8bebNa0krb4CW7W8Il0MnUO-h6wMfocZr6HpfrKoMJHGRvBuh6uVnULRGL1ZjgfqjduCSYF7r24PLHS1V-nIbaa-4-WDIojA"
entityData, err := authorisationMiddleware.Parse(token)
if err != nil {
fmt.Println(err)
}
fmt.Printf("%+v\n", entityData)
}
$ go run main.go
{"created_at":"2022-06-29T11:25:38.057373Z","namespace":"main","event":"GetPermissionsBundle: starting permissions bundle request","severity":3,"data":{"uri":"http://localhost:25400/v1/permissions-bundle"}}
{"created_at":"2022-06-29T11:25:38.063618Z","namespace":"main","event":"GetPermissionsBundle: request successfully executed","severity":3,"data":{"resp.StatusCode":200}}
{"created_at":"2022-06-29T11:25:38.063869Z","namespace":"main","event":"GetPermissionsBundle: returning requested permissions to caller","severity":3}
&{UserID:1cf1e025-2ad6-4d54-86db-13a9a2711858 Groups:[afe98049-0758-4ba8-bd07-f968f9ebad1d]}
The authorisation library both parses the JWT token and checks authorisation permissions as a combined convenience function. It is also possible to perform these steps separately as shown below:
// main.go
package main
import (
"context"
"fmt"
"reflect"
"github.com/ONSdigital/dp-authorisation/v2/authorisation"
"github.com/ONSdigital/dp-authorisation/v2/authorisationtest"
"github.com/ONSdigital/dp-authorisation/v2/permissions"
)
func main() {
ctx := context.Background()
// the following retrieves auth config values used in local dev and testing. For other environments, ensure you set IDENTITY_WEB_KEY_SET_URL env variable – https://github.com/ONSdigital/dp-authorisation/blob/master/v2/authorisation/config.go#L5-L15
cfg := authorisation.NewDefaultConfig()
ExpectedEntityData := permsdk.EntityData{
UserID: "[email protected]",
Groups: []string{"role-admin"},
}
// Parse JWT token and retrieve entity Data
jwtParser, err := authorisation.NewCognitoRSAParser(cfg.JWTVerificationPublicKeys)
if err != nil {
println(err)
}
// NOTE: If you retrieve the token from florence, ensure to strip out the `Bearer` string preceding the token.
token := "eyJraWQiOiJOZUtiNjUxOTRKbz0iLCJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhYWFhYWFhYS1iYmJiLWNjY2MtZGRkZC1lZWVlZWVlZWVlZWUiLCJkZXZpY2Vfa2V5IjoiYWFhYWFhYWEtYmJiYi1jY2NjLWRkZGQtZWVlZWVlZWVlZWVlIiwiY29nbml0bzpncm91cHMiOlsicm9sZS1hZG1pbiJdLCJ0b2tlbl91c2UiOiJhY2Nlc3MiLCJzY29wZSI6ImF3cy5jb2duaXRvLnNpZ25pbi51c2VyLmFkbWluIiwiYXV0aF90aW1lIjoxNTYyMTkwNTI0LCJpc3MiOiJodHRwczovL2NvZ25pdG8taWRwLnVzLXdlc3QtMi5hbWF6b25hd3MuY29tL3VzLXdlc3QtMl9leGFtcGxlIiwiZXhwIjo5OTk5OTk5OTk5OTksImlhdCI6MTU2MjE5MDUyNCwianRpIjoiYWFhYWFhYWEtYmJiYi1jY2NjLWRkZGQtZWVlZWVlZWVlZWVlIiwiY2xpZW50X2lkIjoiNTdjYmlzaGs0ajI0cGFiYzEyMzQ1Njc4OTAiLCJ1c2VybmFtZSI6ImphbmVkb2VAZXhhbXBsZS5jb20ifQ.ZmZkZlrAtFxG5PnfC7dOru_KykJJ5f5bu7YkpCaNMwjXtBM8hWmiWk88QGfbx9kqI1wYs479cFrZ0FablR_38ek6RH9yAVaxTk7ZKOBUqSbVbIB-82B5iRXI8vLquZYjZEunH7LDv0kfZbsqoCZCe3nAJU5aV-hVMF1Cbz2LgIymRqMFqDxD2YIu5RgRHc71FtPebNfMTFCmnTs2v5b4KOqDNZZuab7eLMc-B941M6XyfdF7I6RRfvxw7xTv-qi6ZhGzkbe7K2rlxUmSwjQRDPYrOD7qji_V7yxon9okPyvpTHp-8yaHyrVv1CUCHX67c3OSRT7x3gZqRcPYpEZmScyj7M38Kwn04CKcNqc4ouozIBqhtkBgnCWJuaj1wl7AxQDRR5_F_IS962Y8t2IfU-UurqoZAZvQqWWyeBVJB3aIKrhSJHx62ayZVjd3u2za2WS8aZT97pjEuKLjSoYcgdEqnL9_fKdZc4Vv3QBZmtj_rZsb-zOrj2u_kMox8g-uaIC6ehkNucmM-HEfSuTA7nf_pPNw9c6HLDXJizGWMBVf18K94HPFTyWtJWB7yhXCuV9Kulp9iVGEn8230e6mn7ui0z8lU8R-KpZm3_aPTXBXKsUVdsoj0ZK5sd4y5ARdZ5BOGurT5NpMsw8avW-CqMF0dPY2kmUv3EtBE6dkvdg"
ParsedEntityData, err := jwtParser.Parse(token)
if err != nil {
println(err)
}
fmt.Printf("%+v\n", ParsedEntityData)
fmt.Println("valid parsed JWT: ", reflect.DeepEqual(ParsedEntityData, &ExpectedEntityData))
// Check authorisation permissions
fakePermissionsAPI := authorisationtest.NewFakePermissionsAPI()
permissionChecker := permissions.NewChecker(
ctx,
fakePermissionsAPI.URL(),
cfg.PermissionsCacheUpdateInterval,
cfg.PermissionsMaxCacheTime,
)
permission := "users:create"
hasPermission, err := permissionChecker.HasPermission(ctx, *ParsedEntityData, permission, nil)
if err != nil {
println(err)
}
fmt.Println("entity has permission: ", hasPermission)
}
$ go run main.go
&{UserID:[email protected] Groups:[role-admin]}
valid parsed JWT: true
{"created_at":"2022-05-12T08:27:57.344261Z","namespace":"main","event":"GetPermissionsBundle: starting permissions bundle request","severity":3,"data":{"uri":"http://127.0.0.1:57766/v1/permissions-bundle"}}
{"created_at":"2022-05-12T08:27:57.345428Z","namespace":"main","event":"GetPermissionsBundle: request successfully executed","severity":3,"data":{"resp.StatusCode":200}}
{"created_at":"2022-05-12T08:27:57.345538Z","namespace":"main","event":"GetPermissionsBundle: returning requested permissions to caller","severity":3}
entity has permission: true