Creating Docker Registry Token Authentication Server with Go

Recently, I was working on a project that uses a private docker registry to store docker images produced by users. The access to these images needs to be controlled so that user foo MUST not be able to access(pull/push) images that belongs to user bar . Also, a user should be able to authenticate with the private docker registry from their local or remote development machine with the famous docker login command, additionally users should be able to perform basic docker operations — docker push, pull etc with proper authentication and authorization. This is similar to Google cloud’s container registry.

The Problem

You can easily get up and running with the registry docker image, by running the command below, you’ll have a docker registry running on your machine:

docker run -d -p 5000:5000 --restart always --name registry registry:2

Boom! docker registry is up and running on localhost:5000 . But this is limited because of the following reasons

  1. If this were to be on a cloud VM/server, anyone with an access to the host IP would be able to pull and push images to our precious private docker registry.

  2. You can take it a step further by configuring the registry to use a htpasswd file for authentication. By default, docker registry uses HTTP basic authentication to authenticates with the registry, the attached username and password would be compared against the values in the htpasswd file and if matches, all access would be granted to the client. As you can imagine, this is not what we want. Remember, we want each user to be able to authenticate individually, we also want different access level for these users, e.g we might want user foo to only be able to pull images while we might want user bar to be able to push and pull images.

The Solution

One of the methods of authenticating with a registry server is token method, where, according to a specification that can be found here, we can create a custom, trusted token authentication server. The job of this token server is very specific, respond to successful authentication and authorization requests with a specially crafted JWT token, the documentation on how to create this token can be found here.

The question here now is: How can we achieve our goal by using this token authentication method.

The solution we’re building is programming language agnostic because of the specification we’re building on-top, although Go programming language is used for this example, you can easily adapt the solution to your favourite programming language.

Note: This post assumed you have the following installed on your machine.

Firstly, let’s configure a bare minimum docker registry server using docker-compose

1version: "3"
3  registry:
4    restart: on-failure
5    image: registry:2
6    ports:
7      - 5010:5000

When you run docker-compose up , a docker registry will start running on localhost:5010 . Note: i choose port :5010 for my new docker registry, you can use any available port on your machine. Let’s perform some operations to make sure our local docker registry works as expected.

$ docker pull ubuntu*
$ docker tag localhost:5010/ubuntu*
$ docker push localhost:5010/ubuntu*

The above commands pull an image from public docker registry(dockerhub) and then tag the image to include the url of our local registry, this instructs docker to push the image to the docker registry running at localhost:5010 when docker push is invoked, if all goes well, you should see the push progress indicator in your terminal, yay!!!

Now, let’s configure our new docker registry server to use token authentication. First thing we want to do is to create a SSL certificate because it is a requirement for token authentication to work, registry server will forcefully quit when it is configured to use token authentication and SSL certificate is not provided. For this purpose, we’ll generate a self-signed SSL certificate with openssl. Before we continue, let’s create a basic project setup for our token authentication server. I am creating a new Go project, with go mod as my dependency manager.

Now, we’ve sorted SSL certificate generation. Note: In production, a valid SSL certificate must be provisioned with LetsEncrypt or other similar services. Let’s move on to configuring our docker registry server to use token authentication and the self-signed certificate.

 1version: "3"
 3  registry:
 4    restart: on-failure
 5    image: registry:2
 6    ports:
 7      - 5010:5000
 8    environment:
10      - REGISTRY_AUTH=token
11      - REGISTRY_AUTH_TOKEN_REALM=https://localhost:5011/auth
12      - REGISTRY_AUTH_TOKEN_SERVICE=Authentication
13      - REGISTRY_AUTH_TOKEN_ISSUER=Example Issuer
14      - REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/mnt/local/certs/RootCA.crt
15      - REGISTRY_HTTP_TLS_CERTIFICATE=/mnt/local/certs/RootCA.crt
16      - REGISTRY_HTTP_TLS_KEY=/mnt/local/certs/RootCA.key
17    volumes:
18      - "${HOME}/mnt/auth/registry/data:/mnt/registry/data"
19      - "./certs:/mnt/local/certs"

The configuration are passed through environment variables, you can also mount a config.yml file into the container but generally, configuring with environment variable is easier and straightforward. As you can see from the docker-compose.yml snippet above, we mounted our certs directory into the container so that we can use our SSL certificate to secure our docker registry server.

Creating the token authentication server

First of all, we need to understand what happens when users make an attempt to access our private docker registry without authentication. Basically, if the registry is configured to use token authentication like we’re doing, the configured token server will be called with the following parameters https://${SAMPLE_REGISTRY}/auth?,push , the token server should first make an attempt to authenticate the user using the authentication credentials provided along with the request (HTTP basic authentication as of Docker 1.8), if the authentication succeeds, an authorization should follow using the scope parameter added to the request’s query parameters, the format of this scope is scope=repository:samalba/my-app:pull,push which contains basically the type, repository name, and **the requested actions. **These information should be use to determine if user should be given permission or not. Once it is decided whether to give users permission or not, authorization operation should return the list of permissions that should be granted the authorized user or an empty list should be return if the user is unauthorized to access the requested resources.

Let’s write some code to make all of these explanation makes sense.

 1import (
 2    ""
 3    "crypto/tls"
 4    "crypto/x509"
 6type tokenServer struct {
 7	privateKey libtrust.PrivateKey
 8	pubKey     libtrust.PublicKey
 9	crt, key   string
11// newTokenServer creates a new tokenServer
12func newTokenServer(crt, key string) (*tokenServer, error) {
13	pk, prk, err := loadCertAndKey(crt, key)
14	if err != nil {
15		return nil, err
16	}
17	t := &tokenServer{privateKey: prk, pubKey: pk, crt: crt, key: key}
18	return t, nil
21// loadCertAndKey from filesystem
22func loadCertAndKey(certFile, keyFile string) (libtrust.PublicKey, libtrust.PrivateKey, error) {
23	cert, err := tls.LoadX509KeyPair(certFile, keyFile)
24	if err != nil {
25		return nil, nil, err
26	}
27	x509Cert, err := x509.ParseCertificate(cert.Certificate[0])
28	if err != nil {
29		return nil, nil, err
30	}
31	pk, err := libtrust.FromCryptoPublicKey(x509Cert.PublicKey)
32	if err != nil {
33		return nil, nil, err
34	}
35	prk, err := libtrust.FromCryptoPrivateKey(cert.PrivateKey)
36	if err != nil {
37		return nil, nil, err
38	}

The above code setups the basic structure of our token server. Lines 6-10 defines a structure to hold some important values. Lines 12-19 is a helper function to form a usable tokenServer structure, this function basically loads in the certificate data into go struct so that we can use them later on to sign the JWTs we’re going to be producing. Lines 22-40 is a helper function to load raw certificate data into libtrust.Privatekey and libtrust.Publickey .

 1type Option struct {
 2	issuer, typ, name, account, service string
 3	actions []string // requested actions
 6type Token struct {
 7	Token       string `json:"token"`
 8	AccessToken string `json:"access_token"`
11func (srv *tokenServer) createToken(opt *Option, actions []string) (*Token, error) {
12	// sign any string to get the used signing Algorithm for the private key
13	_, algo, err := srv.privateKey.Sign(strings.NewReader("AUTH"), 0)
14	if err != nil {
15		return nil, err
16	}
17	header := token.Header{
18		Type:       "JWT",
19		SigningAlg: algo,
20		KeyID:      srv.pubKey.KeyID(),
21	}
22	headerJson, err := json.Marshal(header)
23	if err != nil {
24		return nil, err
25	}
26	now := time.Now().Unix()
27	exp := now + time.Now().Add(24*time.Hour).Unix()
28	claim := token.ClaimSet{
29		Issuer:     opt.issuer,
30		Subject:    opt.account,
31		Audience:   opt.service,
32		Expiration: exp,
33		NotBefore:  now - 10,
34		IssuedAt:   now,
35		JWTID:      fmt.Sprintf("%d", rand.Int63()),
36		Access:     []*token.ResourceActions{},
37	}
38	claim.Access = append(claim.Access, &token.ResourceActions{
39		Type:    opt.typ,
40		Name:,
41		Actions: actions,
42	})
43	claimJson, err := json.Marshal(claim)
44	if err != nil {
45		return nil, err
46	}
47	payload := fmt.Sprintf("%s%s%s", encodeBase64(headerJson), token.TokenSeparator, encodeBase64(claimJson))
48	sig, sigAlgo, err := srv.privateKey.Sign(strings.NewReader(payload), 0)
49	if err != nil && sigAlgo != algo {
50		return nil, err
51	}
52	tk := fmt.Sprintf("%s%s%s", payload, token.TokenSeparator, encodeBase64(sig))
53	return &Token{Token: tk, AccessToken: tk}, nil
57func (srv *tokenServer) createTokenOption(r *http.Request) *Option {
58	opt := &Option{}
59	q := r.URL.Query()
61	opt.service = q.Get("service")
62	opt.account = q.Get("account")
63	opt.issuer = "Sample Issuer" // issuer value must match the value configured via docker-compose
65	parts := strings.Split(q.Get("scope"), ":")
66	if len(parts) > 0 {
67		opt.typ = parts[0] // repository
68	}
69	if len(parts) > 1 {
70 = parts[1] // foo/repoName
71	}
72	if len(parts) > 2 {
73		opt.actions = strings.Split(parts[2], ",") // requested actions
74	}
75	return opt

The above code made up the huge chunk of this whole solution. Lines 2-5 defined a struct Option{} to hold the docker registry request’s parameter, which are basically the values needed to create a valid JWT for our registry client. Lines 7-10 defined a Token{} struct to hold the value of the generated token. Lines 12-55 is a function that takes a Option{} and list of actions granted to authenticating user and then create a valid token according to THIS SPECIFICATION, the code comments are straightforward and should be easy to follow. And lastly, Lines 58-77 parses the request’s data and create an Option{} from it, this will allow us to easily have access to the information we need to create a valid token.

Putting it all together.

 1func (srv *tokenServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 2	username, password, ok := r.BasicAuth()
 3	if !ok {
 4		http.Error(w, "auth credentials not found", http.StatusUnauthorized)
 5		return
 6	}
 7	// compare username and password against your datasets
 8	// our example only allows foo:bar
 9	if username != "foo" || password != "bar" {
10		http.Error(w, "invalid auth credentials", http.StatusUnauthorized)
11		return
12	}
13	// do authorization check
14	opt := srv.createTokenOption(r)
15	actions := srv.authorize(opt)
16	tk, err := srv.createToken(opt, actions)
17	if err != nil {
18		http.Error(w, "server error", http.StatusInternalServerError)
19		return
20	}
21	srv.ok(w, tk)
24func (srv *tokenServer) authorize(opt *Option) []string {
25	// do proper comparison to check for user's access
26	// against the requested actions
27	if opt.account == "foo" {
28		return []string{"pull", "push"}
29	}
30	if opt.account == "bar" {
31		return []string{"pull"}
32	}
33	// unauthorized, no permission is granted
34	return []string{}
37func (srv *tokenServer) run() error {
38	addr := fmt.Sprintf(":%s", os.Getenv("PORT"))
39	http.Handle("/auth", srv)
40	return http.ListenAndServeTLS(addr, srv.crt, srv.key, nil)

tokenServer implements http.Handler function so that we can handle authentication and authorization requests from our private docker registry. The first thing we did was retrieve username and password from the request, as expected we immediately return http 401 error if this values are absent. We also did a static comparison to simulate real life authentication, in production you would normally compare these authentication credentials against a real datastore. And then we went ahead to extract Option{} out of our http request, we then do a dummy authorization by passing opt to a fake authorize function. Again, in real life or production scenario, authorize function would work according to your business logic. And finally, we generate our token using the list of authorized actions returned from calling authorize function. Lines 37-41 setup an https server and therefore our token authentication server is read to issue out valid JWT tokens.

Containerizing the token authentication server

We need to package our token authentication server into a container so that we can run it along the docker registry in a simple docker-compose.yml file.

1FROM alpine:3.2
2RUN apk update && apk add --no-cache ca-certificates
3ADD . /app
5RUN chmod +x /app/sample-auth
6ENTRYPOINT [ "/app/sample-auth" ]

We need to create a bash file, we’re naming it , it’s just a simple file to group our command so that we won’t need to be repeating commands every single time.

 2# Generate a random uuid to use as a docker tag
 3NEW_UUID=$(cat /dev/urandom | LC_CTYPE=C tr -dc 'a-zA-Z0-9' | fold -w 8 | head -n 1)
 4# build binary
 5export GOOS=linux && go build -o sample-auth main.go
 6# build docker image
 7docker build -t "sample-auth" .
 8# tag image
 9docker tag sample-auth localhost:5010/sample-auth:${NEW_UUID}
10# push image to registry running at localhost:5010
11docker push localhost:5010/sample-auth:${NEW_UUID}

Your final docker-compose.yml file should look like the one pasted below —

 1version: "3"
 4  sample-server:
 5    build: ./
 6    container_name: sample-server
 7    restart: on-failure
 8    environment:
 9      - PORT=5008
10    ports:
11      - 5011:5008
12    volumes:
13      - "./certs:/mnt/certs"
15  docker-registry:
16    restart: always
17    image: registry:2
18    ports:
19      - 5010:5000
20    environment:
22      - REGISTRY_AUTH=token
23      - REGISTRY_AUTH_TOKEN_REALM=https://localhost:5011/auth
24      - REGISTRY_AUTH_TOKEN_SERVICE=Authentication
25      - REGISTRY_AUTH_TOKEN_ISSUER=Sample Issuer
26      - REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/mnt/local/certs/RootCA.crt
27      - REGISTRY_HTTP_TLS_CERTIFICATE=/mnt/local/certs/RootCA.crt
28      - REGISTRY_HTTP_TLS_KEY=/mnt/local/certs/RootCA.key
29    volumes:
30      - "${HOME}/mnt/auth/registry/data:/mnt/registry/data"
31      - "./certs:/mnt/local/certs"

Use docker-compose upstart up the app, both registry and the token authentication server should start.

Testing our implementation On first try, the push should be rejected anddocker client should force you to authenticate. Use foo and bar for your username and password respectively and you should get a Login Succeeded response after which you can now push and pull images from our new docker registry. Voila!

Side note: I put together a simple package that does most of the work describes here. If you’re interested, it is here on my Github, also the code written in this post can be found HERE

Feel free to DM me on Twitter if there’s any issue you want to point out or if there’s any way i can help. Happy coding!