As the end of life for Docker registry V1 quickly approaching, the Quadra team has been working hard on the migration to Docker registry v2. We also saw this as a good opportunity to make some improvements to our current authentication setup for the registry, which uses Basic Authentication over HTTPS. After some experimentations, we’ve decided to try out the token based OAuth system, which will not only provide a much more sophisticated access control for our user images, but also allow us to use the same authentication across multiple registries. In this blog post, I will give an overview of OAuth workflow for Docker registries, and explain some of the implementation details which we’ve found to be poorly documented at the moment. Hopefully by the end of this, you will be able to roll out your own OAuth Server with Docker registry!
Just Show Me The Code
A sample implementation of our OAuth server can be found here. It’s a simple Flask app that understands the OAuth workflow and responds with a token that the v2 registry can understand. Instructions on how the setup the project can be found here, along with some explanations on its configurations.
OAuth Workflow
The OAuth authentication workflow for Docker registry can be described with the following steps:
- Client begins with a connection to the image registry
- If the image registry is properly setup with OAuth enabled, it will return a 401 error, and its response will contain information on how to authenticate
- The client then contacts the authorization server as instructed with the previous response from the registry
- The authorization server then returns a token representing the client’s access
- The client makes another request to the image registry, this time with the token embedded in its header
- The image registry tries to validate the token, and if successful, returns the resources requested by the client.
Let’s walk through a concrete example. First, start the demo project in our repo. Then let’s make a request to our local registry:
curl https://192.168.99.100:5000/v2/_catalog
* Trying 192.168.99.100...
* Connected to 192.168.99.100 (192.168.99.100) port 5000 (#0)
* TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
* Server certificate: localhost
> GET /v2/_catalog HTTP/1.1
> Host: 192.168.99.100:5000
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
< Content-Type: application/json; charset=utf-8
< Docker-Distribution-Api-Version: registry/2.0
< Www-Authenticate: Bearer realm="http://192.168.99.100:8080/tokens",service="demo_registry",scope="registry:catalog:*"
< X-Content-Type-Options: nosniff
< Date: Sun, 31 Jan 2016 22:29:49 GMT
< Content-Length: 134
<
{"errors":[{"code":"UNAUTHORIZED","message":"authentication required","detail":[{"Type":"registry","Name":"catalog","Action":"*"}]}]}
As expected, the registry responded with a 401 error. More importantly however, the Www-Authenticate header in the response specifies how the client should authenticate. Let’s break it down:
- Realm: realm tells the client where the OAuth server is located. In our case it points to http://192.168.99.100:8080/tokens
- Service: service tells the OAuth server where the resources are hosted. In our case it’s our demo_registry
- Scope: scope tells the OAuth server what kind of permissions are needed. In our case we are asking for admin access on the catalog endpoint.
Now we should have all the information we need to contact our OAuth server. To be consistent with the way the docker client authenticates, let’s also pass our user credentials in the header in the HTTP Basic Auth format. First, base64 encode our credentials:
$ echo -n username:password | base64
dXNlcm5hbWU6cGFzc3dvcmQ=
Then we can embed the result in our headers as follows:
curl -H "Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=" http://192.168.99.100:8080/tokens?service=demo_registry&scope=registry:catalog:*
* Trying 192.168.99.100...
* Connected to 192.168.99.100 (192.168.99.100) port 8080 (#0)
> GET /tokens?service=demo_registry&scope=registry:catalog:* HTTP/1.1
> Host: 192.168.99.100:8080
> User-Agent: curl/7.43.0
> Accept: */*
> Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
>
< HTTP/1.1 200 OK
< Server: gunicorn/19.4.5
< Date: Sun, 31 Jan 2016 22:58:54 GMT
< Connection: close
< Content-Type: application/json
< Content-Length: 719
<
{
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IllJMkI6N01JUTpIT0I0OjdXN0I6Uk5NTDpaRUZVOkdLMkc6VkM3RTo3UUhHOkdVR1Y6T1FYVTozN0lUIn0.eyJzdWIiOiIiLCJpc3MiOiJkZW1vX29hdXRoX3NlcnZlciIsImFjY2VzcyI6W3sidHlwZSI6InJlZ2lzdHJ5IiwibmFtZSI6ImNhdGFsb2ciLCJhY3Rpb25zIjpbIioiXX1dLCJleHAiOjE0NTQyODQ3MzQsImlhdCI6MTQ1NDI4MTEzNCwibmJmIjoxNDU0MjgxMTM0LCJhdWQiOiJkZW1vX3JlZ2lzdHJ5In0.QYGsEkuFv5Mpg2_2oov3KylQcYZEhXJXGKB_ahDCmya4MUnyprRISFfk3Eovvc5OgGWUQx5-Gl7eSBidVI0z7K29wUV7ITL5prnbwg5pIjxJAYLkzBCmouiAyE24Uxy2vkVtDTicWsWT7H54Ou_v2umv7bQe6JB3t6vYsmb3taiDUI_RTWxfSOp7OK1n6UVFEEUHiV57wP3aWZ60A379a9ZP6sEHKhEi306OvXPyaz804KFH7sTqbSMYf9DP_Gy8Jh04Tw9zKmClk-byct8Hspelw1JytbsQonlKwV9OH30DTCjgaWyNiavTTdfpRmiDRRMRsROjw2JLL8ZMMTZEhQ"
}
As expected, the oauth server responded with our token. With this token, now we can make another request to the registry:
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IllJMkI6N01JUTpIT0I0OjdXN0I6Uk5NTDpaRUZVOkdLMkc6VkM3RTo3UUhHOkdVR1Y6T1FYVTozN0lUIn0.eyJzdWIiOiIiLCJpc3MiOiJkZW1vX29hdXRoX3NlcnZlciIsImFjY2VzcyI6W3sidHlwZSI6InJlZ2lzdHJ5IiwibmFtZSI6ImNhdGFsb2ciLCJhY3Rpb25zIjpbIioiXX1dLCJleHAiOjE0NTQyODQ3MzQsImlhdCI6MTQ1NDI4MTEzNCwibmJmIjoxNDU0MjgxMTM0LCJhdWQiOiJkZW1vX3JlZ2lzdHJ5In0.QYGsEkuFv5Mpg2_2oov3KylQcYZEhXJXGKB_ahDCmya4MUnyprRISFfk3Eovvc5OgGWUQx5-Gl7eSBidVI0z7K29wUV7ITL5prnbwg5pIjxJAYLkzBCmouiAyE24Uxy2vkVtDTicWsWT7H54Ou_v2umv7bQe6JB3t6vYsmb3taiDUI_RTWxfSOp7OK1n6UVFEEUHiV57wP3aWZ60A379a9ZP6sEHKhEi306OvXPyaz804KFH7sTqbSMYf9DP_Gy8Jh04Tw9zKmClk-byct8Hspelw1JytbsQonlKwV9OH30DTCjgaWyNiavTTdfpRmiDRRMRsROjw2JLL8ZMMTZEhQ" https://192.168.99.100:5000/v2/_catalog
* Trying 192.168.99.100...
* Connected to 192.168.99.100 (192.168.99.100) port 5000 (#0)
* TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
* Server certificate: localhost
> GET /v2/_catalog HTTP/1.1
> Host: 192.168.99.100:5000
> User-Agent: curl/7.43.0
> Accept: */*
> Authorization: Bearer
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IllJMkI6N01JUTpIT0I0OjdXN0I6Uk5NTDpaRUZVOkdLMkc6VkM3RTo3UUhHOkdVR1Y6T1FYVTozN0lUIn0.eyJzdWIiOiIiLCJpc3MiOiJkZW1vX29hdXRoX3NlcnZlciIsImFjY2VzcyI6W3sidHlwZSI6InJlZ2lzdHJ5IiwibmFtZSI6ImNhdGFsb2ciLCJhY3Rpb25zIjpbIioiXX1dLCJleHAiOjE0NTQyODQ3MzQsImlhdCI6MTQ1NDI4MTEzNCwibmJmIjoxNDU0MjgxMTM0LCJhdWQiOiJkZW1vX3JlZ2lzdHJ5In0.QYGsEkuFv5Mpg2_2oov3KylQcYZEhXJXGKB_ahDCmya4MUnyprRISFfk3Eovvc5OgGWUQx5-Gl7eSBidVI0z7K29wUV7ITL5prnbwg5pIjxJAYLkzBCmouiAyE24Uxy2vkVtDTicWsWT7H54Ou_v2umv7bQe6JB3t6vYsmb3taiDUI_RTWxfSOp7OK1n6UVFEEUHiV57wP3aWZ60A379a9ZP6sEHKhEi306OvXPyaz804KFH7sTqbSMYf9DP_Gy8Jh04Tw9zKmClk-byct8Hspelw1JytbsQonlKwV9OH30DTCjgaWyNiavTTdfpRmiDRRMRsROjw2JLL8ZMMTZEhQ
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Docker-Distribution-Api-Version: registry/2.0
< X-Content-Type-Options: nosniff
< Date: Sun, 31 Jan 2016 23:58:06 GMT
< Content-Length: 20
<
{"repositories":[]}
Finally, the registry returns our desired response after it validated our token.
The OAuth Token
Now that we are familiar with the OAuth workflow, we should also learn about the OAuth Token and how the OAuth server should generate them.
The Docker Registry accepts a well-known token format called JSON Web Token or JWT as its authentication token. The JWT token consists of three parts separated by periods (.): Header, Claim, and Signature. A typical JWT token will look like this:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IllJMkI6N01JUTpIT0I0OjdXN0I6Uk5NTDpaRUZVOkdLMkc6VkM3RTo3UUhHOkdVR1Y6T1FYVTozN0lUIn0.eyJzdWIiOiIiLCJpc3MiOiJkZW1vX29hdXRoX3NlcnZlciIsImFjY2VzcyI6W3sidHlwZSI6InJlZ2lzdHJ5IiwibmFtZSI6ImNhdGFsb2ciLCJhY3Rpb25zIjpbIioiXX1dLCJleHAiOjE0NTQyODQ3MzQsImlhdCI6MTQ1NDI4MTEzNCwibmJmIjoxNDU0MjgxMTM0LCJhdWQiOiJkZW1vX3JlZ2lzdHJ5In0.QYGsEkuFv5Mpg2_2oov3KylQcYZEhXJXGKB_ahDCmya4MUnyprRISFfk3Eovvc5OgGWUQx5-Gl7eSBidVI0z7K29wUV7ITL5prnbwg5pIjxJAYLkzBCmouiAyE24Uxy2vkVtDTicWsWT7H54Ou_v2umv7bQe6JB3t6vYsmb3taiDUI_RTWxfSOp7OK1n6UVFEEUHiV57wP3aWZ60A379a9ZP6sEHKhEi306OvXPyaz804KFH7sTqbSMYf9DP_Gy8Jh04Tw9zKmClk-byct8Hspelw1JytbsQonlKwV9OH30DTCjgaWyNiavTTdfpRmiDRRMRsROjw2JLL8ZMMTZEhQ
Let’s find out how each part of the JWT token is generated.
Header
A JWT Header typically look like this before encoding (formatted with white space for readability):
{
"typ": "JWT",
"alg": "RS256",
"kid": "ABCD:EFGH:IJKL:MNOP:QRST:UVWX:YZ23:4567:ABCD:EFGH:IJKL:MNOP"
}
The “typ” field specifies the type of the token, and is usually set to “JWT.” The “alg” field specifies the algorithm used to generate the signature part of the token. For simplicity, our OAuth server only supports the RS256 Algorithm. A list of commonly used values for this field is listed below:
Last but not the least, the “kid” field is an unique ID generated from the public part of the signing key. It was the source of a fair bit of frustration for us because there are no documentations on how this field should be generated! It wasn’t until we dug through the Github repository for Docker registry source code that we finally solved it.
So, long story short, for Docker registry, the “kid” field should be implemented as follows:
Take the SHA256 hash of the DER encoded public key, and truncate it to 240 bits. The result of that is then encoded into 12 base32 groups. The final result look like this:
ABCD:EFGH:IJKL:MNOP:QRST:UVWX:YZ23:4567:ABCD:EFGH:IJKL:MNOP
You can find our implementation of the algorithm here.
Now that we have all the fields we need, we can create the actual header: strip all white spaces from the header, the result of which is base64 urlsafe encoded to form the first part of the token.
Claim
Also known as the JWT payload, the claim set for the JWT token typically look like this:
{
"sub": "",
"iss": "demo_oauth_server",
"access": [
{
"type": "registry",
"name": "catalog",
"actions": [
"*"
]
}
],
"exp": 1454284734,
"iat": 1454281134,
"nbf": 1454281134,
"aud": "demo_registry"
}
The “iss” field specifies where the token is issued from, usually the FQDN of the OAuth Server is used. The “aud” field specifies the intended audience of the token, usually this is set to the FQDN of the docker registry. The ‘exp’ field specifies the expiration time of the token in unix time. The ‘nbf’ (not before) field specifies the earliest time when the token is valid. The “iat” field specifies when the token is issued.
The “access” field specifies what kind of permission this token has. In our example, the token granted us “push and pull” permissions on the repository named samalba/my-app. The Oauth server should generate this field based on the access request generated during the first connection to docker registry.
Again, white spaces are stripped from the claim and the result is then base64 urlsafe encoded to form the second part of the token.
Signature
The signature is generated by taking the base64 urlsafe encoded header concatenated with the base64 urlsafe encoded claim using a period and signing it with the key and algorithm specified in the header. Using the header and claim given above, the pseudo code to generate the signature would be the following:
RS256(base64_urlsafe_encode(header) + “.” +
base64_urlsafe_encode(claim), key)
The result is then concatenated to the token as the final part.
A live debugger can be found at https://jwt.io/, and it can be a very useful way to make sure your tokens are valid.
Conclusion
Rolling out our own OAuth server wasn’t easy, and we hope that this blog has provided you with a good starting point so you don’t have to repeat some of the problems we faced. Feel free to play around with the OAuth server app and submit changes or improvements!