Skip to content

Commit 58b1486

Browse files
committed
feat(token-auth-keycloak): add token auth via keycloak using docker protocol
1 parent 2c9f006 commit 58b1486

File tree

8 files changed

+180
-44
lines changed

8 files changed

+180
-44
lines changed

examples/token-auth-keycloak/docker-compose.yml

+3-2
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ services:
2222
- registry-ui-net
2323

2424
ui:
25-
image: joxit/docker-registry-ui:static
25+
image: joxit/docker-registry-ui
2626
environment:
2727
REGISTRY_TITLE: My Private Docker Registry
28-
URL: http://localhost
28+
REGISTRY_URL: http://localhost
29+
SINGLE_REGISTRY: 'true'
2930
networks:
3031
- registry-ui-net
3132

src/components/catalog/catalog.riot

+3-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
5555
display(props, state) {
5656
let repositories = [];
5757
const self = this;
58-
const oReq = new Http();
58+
const oReq = new Http({
59+
onAuthentication: this.props.onAuthentication
60+
});
5961
oReq.addEventListener('load', function () {
6062
if (this.status == 200) {
6163
repositories = JSON.parse(this.responseText).repositories || [];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<!--
2+
Copyright (C) 2016-2021 Jones Magloire @Joxit
3+
4+
This program is free software: you can redistribute it and/or modify
5+
it under the terms of the GNU Affero General Public License as published by
6+
the Free Software Foundation, either version 3 of the License, or
7+
(at your option) any later version.
8+
9+
This program is distributed in the hope that it will be useful,
10+
but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
GNU Affero General Public License for more details.
13+
14+
You should have received a copy of the GNU Affero General Public License
15+
along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
-->
17+
<registry-authentication>
18+
<material-popup opened="{ props.opened }" onClick="{ props.onClose }">
19+
<div slot="title">Sign In to your registry</div>
20+
<div slot="content">
21+
<material-input placeholder="Username" id="username"></material-input>
22+
<material-input placeholder="Password" id="password" onkeyup="{ onKeyUp }" type="password"></material-input>
23+
</div>
24+
<div slot="action">
25+
<material-button class="dialog-button" waves-color="rgba(158,158,158,.4)" onClick="{ signIn }">
26+
Sign In
27+
</material-button>
28+
<material-button class="dialog-button" waves-color="rgba(158,158,158,.4)" onClick="{ props.onClose }">
29+
Cancel
30+
</material-button>
31+
</div>
32+
</material-popup>
33+
<script>
34+
export default {
35+
signIn() {
36+
const {
37+
realm,
38+
service,
39+
scope,
40+
onAuthenticated,
41+
onClose
42+
} = this.props;
43+
const username = this.$('#username input').value;
44+
const password = this.$('#password input').value;
45+
const req = new XMLHttpRequest()
46+
req.addEventListener('loadend', () => {
47+
try {
48+
const bearer = JSON.parse(req.responseText);
49+
onAuthenticated(bearer)
50+
localStorage.setItem('registry.token', bearer.token);
51+
localStorage.setItem('registry.issued_at', bearer.issued_at);
52+
localStorage.setItem('registry.expires_in', bearer.expires_in);
53+
onClose();
54+
} catch (e) {
55+
console.log(e);
56+
}
57+
})
58+
req.open('GET', `${realm}?service=${service}&scope=${scope}`)
59+
req.setRequestHeader('Authorization', `Basic ${btoa(`${username}:${password}`)}`)
60+
req.send()
61+
},
62+
onKeyUp(event) {
63+
// if keyCode is Enter
64+
if (event.keyCode === 13) {
65+
this.signIn();
66+
}
67+
},
68+
69+
}
70+
</script>
71+
</registry-authentication>

src/components/docker-registry-ui.riot

+32-3
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
2828
<route path="{baseRoute}">
2929
<catalog registry-url="{ state.registryUrl }" registry-name="{ state.name }"
3030
catalog-elements-limit="{ state.catalogElementsLimit }" on-notify="{ notifySnackbar }"
31-
filter-results="{ state.filter }" />
31+
filter-results="{ state.filter }" on-authentication="{ onAuthentication }" />
3232
</route>
3333
<route path="{baseRoute}taglist/(.*)">
3434
<tag-list registry-url="{ state.registryUrl }" registry-name="{ state.name }" pull-url="{ state.pullUrl }"
3535
image="{ router.getTagListImage() }" show-content-digest="{props.showContentDigest}"
3636
is-image-remove-activated="{props.isImageRemoveActivated}" on-notify="{ notifySnackbar }"
37-
filter-results="{ state.filter }"></tag-list>
37+
filter-results="{ state.filter }" on-authentication="{ onAuthentication }"></tag-list>
3838
</route>
3939
<route path="{baseRoute}taghistory/(.*)">
4040
<tag-history registry-url="{ state.registryUrl }" registry-name="{ state.name }" pull-url="{ state.pullUrl }"
4141
image="{ router.getTagHistoryImage() }" tag="{ router.getTagHistoryTag() }"
42-
is-image-remove-activated="{props.isImageRemoveActivated}" on-notify="{ notifySnackbar }"></tag-history>
42+
is-image-remove-activated="{ props.isImageRemoveActivated }" on-notify="{ notifySnackbar }" on-authentication="{ onAuthentication }"></tag-history>
4343
</route>
4444
</router>
45+
<registry-authentication realm="{ state.realm }" scope="{ state.scope }" service="{ state.service }"
46+
on-close="{ onAuthenticationClose }" on-authenticated="{ state.onAuthenticated }"
47+
opened="{ state.authenticationDialogOpened }"></registry-authentication>
4548
<material-snackbar message="{ state.snackbarMessage }" is-error="{ state.snackbarIsError }"></material-snackbar>
4649
</main>
4750
<footer>
@@ -70,6 +73,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
7073
import TagHistory from './tag-history/tag-history.riot';
7174
import DialogsMenu from './dialogs/dialogs-menu.riot';
7275
import SearchBar from './search-bar.riot'
76+
import RegistryAuthentication from './dialogs/registry-authentication.riot';
7377
import {
7478
stripHttps,
7579
getRegistryServers
@@ -83,6 +87,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
8387
TagHistory,
8488
DialogsMenu,
8589
SearchBar,
90+
RegistryAuthentication,
8691
Router,
8792
Route
8893
},
@@ -107,6 +112,30 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
107112
snackbarMessage: 'Registry server changed to `' + registryUrl + '`.'
108113
})
109114
},
115+
onAuthentication(opts, onAuthenticated) {
116+
const bearer = {
117+
token: localStorage.getItem('registry.token'),
118+
issued_at: localStorage.getItem('registry.issued_at'),
119+
expires_in: localStorage.getItem('registry.expires_in')
120+
}
121+
if (bearer.token && bearer.issued_at && bearer.expires_in &&
122+
(new Date().getTime() - new Date(bearer.issued_at).getTime()) < (bearer.expires_in * 1000)) {
123+
onAuthenticated(bearer)
124+
} else if (opts) {
125+
this.update({
126+
authenticationDialogOpened: true,
127+
onAuthenticated,
128+
...opts
129+
})
130+
} else {
131+
onAuthenticated()
132+
}
133+
},
134+
onAuthenticationClose() {
135+
this.update({
136+
authenticationDialogOpened: false
137+
})
138+
},
110139
pullUrl(registryUrl, pullUrl) {
111140
const url = pullUrl ||
112141
(registryUrl && registryUrl.length > 0 && registryUrl) ||

src/components/tag-history/tag-history.riot

+9-2
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
5050
},
5151
onBeforeMount(props, state) {
5252
state.elements = [];
53-
state.image = new DockerImage(props.image, props.tag, true, props.registryUrl, props.onNotify);
53+
state.image = new DockerImage(props.image, props.tag, {
54+
list: true,
55+
registryUrl: props.registryUrl,
56+
onNotify: props.onNotify,
57+
onAuthentication: props.onAuthentication
58+
});
5459
state.image.fillInfo()
60+
},
61+
onMounted(props, state) {
5562
state.image.on('blobs', this.processBlobs);
56-
state.image.on('list', this.multiArchList)
63+
state.image.on('list', this.multiArchList);
5764
},
5865
onTabChanged(arch, idx) {
5966
const state = this.state;

src/components/tag-list/tag-list.riot

+8-2
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
8282
display(props, state) {
8383
state.tags = [];
8484
const self = this;
85-
const oReq = new Http();
85+
const oReq = new Http({
86+
onAuthentication: props.onAuthentication
87+
});
8688
oReq.addEventListener('load', function () {
8789
if (this.status == 200) {
8890
const tags = (JSON.parse(this.responseText).tags || [])
89-
.map(tag => new DockerImage(props.image, tag, null, props.registryUrl, props.onNotify))
91+
.map(tag => new DockerImage(props.image, tag, {
92+
registryUrl: props.registryUrl,
93+
onNotify: props.onNotify,
94+
onAuthentication: props.onAuthentication
95+
}))
9096
.sort(compare);
9197
window.requestAnimationFrame(self.onResize);
9298
self.update({

src/scripts/docker-image.js

+18-15
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,16 @@ export function compare(e1, e2) {
4646
}
4747

4848
export class DockerImage {
49-
constructor(name, tag, list, registryUrl, onNotify) {
49+
constructor(name, tag, { list, registryUrl, onNotify, onAuthentication }) {
5050
this.name = name;
5151
this.tag = tag;
52-
this.list = list;
53-
this.registryUrl = registryUrl;
5452
this.chars = 0;
55-
this.onNotify = onNotify;
53+
this.opts = {
54+
list,
55+
registryUrl,
56+
onNotify,
57+
onAuthentication,
58+
};
5659
observable(this);
5760
this.on('get-size', function () {
5861
if (this.size !== undefined) {
@@ -90,15 +93,15 @@ export class DockerImage {
9093
return;
9194
}
9295
this._fillInfoWaiting = true;
93-
const oReq = new Http();
96+
const oReq = new Http({ onAuthentication: this.opts.onAuthentication });
9497
const self = this;
9598
oReq.addEventListener('loadend', function () {
9699
if (this.status == 200 || this.status == 202) {
97100
const response = JSON.parse(this.responseText);
98101
if (response.mediaType === 'application/vnd.docker.distribution.manifest.list.v2+json') {
99102
self.trigger('list', response);
100103
const manifest = response.manifests[0];
101-
const image = new DockerImage(self.name, manifest.digest, false, self.registryUrl, self.onNotify);
104+
const image = new DockerImage(self.name, manifest.digest, { ...self.opts, list: false });
102105
eventTransfer(image, self);
103106
image.fillInfo();
104107
self.variants = [image];
@@ -115,26 +118,26 @@ export class DockerImage {
115118
self.digest = digest;
116119
self.trigger('content-digest', digest);
117120
if (!digest) {
118-
self.onNotify(ERROR_CAN_NOT_READ_CONTENT_DIGEST);
121+
self.opts.onNotify(ERROR_CAN_NOT_READ_CONTENT_DIGEST);
119122
}
120123
});
121124
self.getBlobs(response.config.digest);
122125
} else if (this.status == 404) {
123-
self.onNotify(`Manifest for ${self.name}:${self.tag} not found`, true);
126+
self.opts.onNotify(`Manifest for ${self.name}:${self.tag} not found`, true);
124127
} else {
125-
self.onNotify(this.responseText);
128+
self.opts.onNotify(this.responseText);
126129
}
127130
});
128-
oReq.open('GET', this.registryUrl + '/v2/' + self.name + '/manifests/' + self.tag);
131+
oReq.open('GET', `${this.opts.registryUrl}/v2/${self.name}/manifests/${self.tag}`);
129132
oReq.setRequestHeader(
130133
'Accept',
131134
'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json' +
132-
(self.list ? ', application/vnd.docker.distribution.manifest.list.v2+json' : '')
135+
(self.opts.list ? ', application/vnd.docker.distribution.manifest.list.v2+json' : '')
133136
);
134137
oReq.send();
135138
}
136139
getBlobs(blob) {
137-
const oReq = new Http();
140+
const oReq = new Http({ onAuthentication: this.opts.onAuthentication });
138141
const self = this;
139142
oReq.addEventListener('loadend', function () {
140143
if (this.status == 200 || this.status == 202) {
@@ -153,12 +156,12 @@ export class DockerImage {
153156
self.trigger('creation-date', self.creationDate);
154157
self.trigger('blobs', self.blobs);
155158
} else if (this.status == 404) {
156-
self.onNotify(`Blobs for ${self.name}:${self.tag} not found`, true);
159+
self.opts.onNotify(`Blobs for ${self.name}:${self.tag} not found`, true);
157160
} else {
158-
self.onNotify(this.responseText);
161+
self.opts.onNotify(this.responseText);
159162
}
160163
});
161-
oReq.open('GET', this.registryUrl + '/v2/' + self.name + '/blobs/' + blob);
164+
oReq.open('GET', `${this.opts.registryUrl}/v2/${self.name}/blobs/${blob}`);
162165
oReq.setRequestHeader(
163166
'Accept',
164167
'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json'

src/scripts/http.js

+36-19
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616
*/
1717

1818
export class Http {
19-
constructor() {
19+
constructor(opts) {
2020
this.oReq = new XMLHttpRequest();
2121
this.oReq.hasHeader = hasHeader;
2222
this.oReq.getErrorMessage = getErrorMessage;
2323
this._events = {};
2424
this._headers = {};
25+
this.onAuthentication = opts && opts.onAuthentication;
26+
this.withCredentials = opts && opts.withCredentials;
2527
}
2628

2729
getContentDigest(cb) {
@@ -34,9 +36,7 @@ export class Http {
3436
cb(
3537
'sha256:' +
3638
Array.from(new Uint8Array(buffer))
37-
.map(function (byte) {
38-
return byte.toString(16).padStart(2, '0');
39-
})
39+
.map((byte) => byte.toString(16).padStart(2, '0'))
4040
.join('')
4141
);
4242
});
@@ -52,21 +52,28 @@ export class Http {
5252
switch (e) {
5353
case 'loadend': {
5454
self.oReq.addEventListener('loadend', function () {
55-
if (this.status == 401) {
56-
const req = new XMLHttpRequest();
57-
req._url = self._url;
58-
req.open(self._method, self._url);
59-
for (key in self._events) {
60-
req.addEventListener(key, self._events[key]);
61-
}
62-
for (key in self._headers) {
63-
req.setRequestHeader(key, self._headers[key]);
64-
}
65-
req.withCredentials = true;
66-
req.hasHeader = Http.hasHeader;
67-
req.getErrorMessage = Http.getErrorMessage;
68-
self.oReq = req;
69-
req.send();
55+
if (this.status == 401 && !this.withCredentials) {
56+
const tokenAuth = parseAuthenticateHeader(this.getResponseHeader('www-authenticate'));
57+
self.onAuthentication(tokenAuth, (bearer) => {
58+
const req = new XMLHttpRequest();
59+
req._url = self._url;
60+
req.open(self._method, self._url);
61+
for (let key in self._events) {
62+
req.addEventListener(key, self._events[key]);
63+
}
64+
for (let key in self._headers) {
65+
req.setRequestHeader(key, self._headers[key]);
66+
}
67+
if (bearer && bearer.token) {
68+
req.setRequestHeader('Authorization', `Bearer ${bearer.token}`)
69+
} else {
70+
req.withCredentials = true;
71+
}
72+
req.hasHeader = hasHeader;
73+
req.getErrorMessage = Http.getErrorMessage;
74+
self.oReq = req;
75+
req.send();
76+
});
7077
} else {
7178
f.bind(this)();
7279
}
@@ -99,6 +106,9 @@ export class Http {
99106
this._method = m;
100107
this._url = u;
101108
this.oReq._url = u;
109+
if (this.withCredentials) {
110+
this.oReq.withCredentials = true;
111+
}
102112
this.oReq.open(m, u);
103113
}
104114

@@ -139,3 +149,10 @@ const getErrorMessage = function () {
139149
'`'
140150
);
141151
};
152+
153+
const AUTHENTICATE_HEADER_REGEX = /Bearer realm="(?<realm>[^"]+)",service="(?<service>[^"]+)",scope="(?<scope>[^"]+)"/;
154+
155+
const parseAuthenticateHeader = (header) => {
156+
const exec = AUTHENTICATE_HEADER_REGEX.exec(header);
157+
return exec && exec.groups;
158+
};

0 commit comments

Comments
 (0)