Skip to content

Commit 451b2f3

Browse files
OIDC user/password and access/refresh token authentication support (#100)
1 parent f5a3a2b commit 451b2f3

21 files changed

+893
-197
lines changed

.github/workflows/tests.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ jobs:
1717
with:
1818
node-version: '16.x'
1919
- name: "Run tests"
20+
env:
21+
OKTA_DUMMY_CI_PW: ${{ secrets.OKTA_DUMMY_CI_PW }}
22+
WCS_DUMMY_CI_PW: ${{ secrets.WCS_DUMMY_CI_PW }}
2023
run: |
2124
npm install
2225
ci/run_dependencies.sh
@@ -37,4 +40,4 @@ jobs:
3740
- run: npm ci && npm run build
3841
- run: npm publish
3942
env:
40-
NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTOMATION_TOKEN }}
43+
NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTOMATION_TOKEN }}

ci/compose.sh

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/bin/bash
2+
3+
function ls_compose {
4+
ls ci | grep 'docker-compose'
5+
}
6+
7+
function exec_all {
8+
for file in $(ls_compose); do
9+
docker-compose -f $(echo "ci/${file} ${1}")
10+
done
11+
}
12+
13+
function compose_up_all {
14+
exec_all "up -d"
15+
}
16+
17+
function compose_down_all {
18+
exec_all "down --remove-orphans"
19+
}
20+
21+
function all_weaviate_ports {
22+
echo "8080 8082 8083"
23+
}

ci/docker-compose-okta.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
version: '3.4'
3+
services:
4+
weaviate-auth-okta-users:
5+
command:
6+
- --host
7+
- 0.0.0.0
8+
- --port
9+
- '8082'
10+
- --scheme
11+
- http
12+
- --write-timeout=600s
13+
image: semitechnologies/weaviate:1.15.4-b7811d4
14+
ports:
15+
- 8082:8082
16+
restart: on-failure:0
17+
environment:
18+
PERSISTENCE_DATA_PATH: '/var/lib/weaviate'
19+
AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'false'
20+
AUTHENTICATION_OIDC_ENABLED: 'true'
21+
AUTHENTICATION_OIDC_CLIENT_ID: '0oa7iz2g41rNxv95B5d7'
22+
AUTHENTICATION_OIDC_ISSUER: 'https://dev-32300990.okta.com/oauth2/aus7iz3tna3kckRWS5d7'
23+
AUTHENTICATION_OIDC_USERNAME_CLAIM: 'sub'
24+
AUTHENTICATION_OIDC_GROUPS_CLAIM: 'groups'
25+
AUTHORIZATION_ADMINLIST_ENABLED: 'true'
26+
AUTHORIZATION_ADMINLIST_USERS: 'test@test.de'
27+
AUTHENTICATION_OIDC_SCOPES: 'openid,email'
28+
...

ci/docker-compose-wcs.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
version: '3.4'
3+
services:
4+
weaviate-auth-wcs:
5+
command:
6+
- --host
7+
- 0.0.0.0
8+
- --port
9+
- '8083'
10+
- --scheme
11+
- http
12+
- --write-timeout=600s
13+
image: semitechnologies/weaviate:1.16.5
14+
ports:
15+
- 8083:8083
16+
restart: on-failure:0
17+
environment:
18+
PERSISTENCE_DATA_PATH: '/var/lib/weaviate'
19+
AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'false'
20+
AUTHENTICATION_OIDC_ENABLED: 'true'
21+
AUTHENTICATION_OIDC_CLIENT_ID: 'wcs'
22+
AUTHENTICATION_OIDC_ISSUER: 'https://auth.wcs.api.semi.technology/auth/realms/SeMI'
23+
AUTHENTICATION_OIDC_USERNAME_CLAIM: 'email'
24+
AUTHENTICATION_OIDC_GROUPS_CLAIM: 'groups'
25+
AUTHORIZATION_ADMINLIST_ENABLED: 'true'
26+
AUTHORIZATION_ADMINLIST_USERS: 'ms_2d0e007e7136de11d5f29fce7a53dae219a51458@existiert.net'
27+
AUTHENTICATION_OIDC_SCOPES: 'openid,email'
28+
...

docker-compose.yml renamed to ci/docker-compose.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
---
12
version: '3.4'
23
services:
34
weaviate:
4-
image: semitechnologies/weaviate:1.16.0
5+
image: semitechnologies/weaviate:1.16.5
56
restart: on-failure:0
67
ports:
78
- "8080:8080"
@@ -25,3 +26,4 @@ services:
2526
EXTENSIONS_STORAGE_ORIGIN: http://weaviate:8080
2627
NEIGHBOR_OCCURRENCE_IGNORE_PERCENTILE: 5
2728
ENABLE_COMPOUND_SPLITTING: 'false'
29+
...

ci/run_dependencies.sh

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,31 @@
11
#!/bin/bash
22

3+
. ./ci/compose.sh
4+
35
echo "Stop existing session if running"
4-
docker-compose down
6+
compose_down_all
57
rm -rf weaviate-data || true
68

79
echo "Run Docker compose"
8-
docker-compose up -d
10+
compose_up_all
911

1012
echo "Wait until weaviate is up"
1113

12-
# pulling all images usually takes < 3 min
13-
# starting weaviate usuall takes < 2 min
14-
i="0"
15-
curl localhost:8080/v1/meta >/dev/null 2>&1
16-
while [ $? -ne 0 ]; do
17-
i=$[$i+5]
18-
echo "Sleep $i"
19-
sleep 5
20-
if [ $i -gt 300 ]; then
21-
echo "Weaviate did not start in time"
22-
docker-compose logs
23-
exit 1
24-
fi
25-
curl localhost:8080/v1/meta >/dev/null 2>&1
14+
for port in $(all_weaviate_ports); do
15+
# pulling all images usually takes < 3 min
16+
# starting weaviate usually takes < 2 min
17+
i="0"
18+
STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}" localhost:"$port"/v1/.well-known/ready)
19+
20+
while [ "$STATUS_CODE" -ne 200 ]; do
21+
i=$(($i+5))
22+
echo "Sleep $i"
23+
sleep 5
24+
if [ $i -gt 300 ]; then
25+
echo "Weaviate did not start in time"
26+
exit 1
27+
fi
28+
STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}" localhost:"$port"/v1/.well-known/ready)
29+
done
30+
echo "Weaviate on port $port is up and running"
2631
done
27-
echo "Weaviate is up and running"

ci/stop_dependencies.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
#!/bin/bash
22

3-
docker-compose down
3+
. ./ci/compose.sh
4+
5+
compose_down_all
46
rm -rf weaviate-data || true

cluster/journey.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
const weaviate = require("../index");
22
const { createTestFoodSchemaAndData, cleanupTestFood, PIZZA_CLASS_NAME, SOUP_CLASS_NAME } = require("../utils/testData");
33

4-
const EXPECTED_WEAVIATE_VERSION = "1.16.0"
5-
const EXPECTED_WEAVIATE_GIT_HASH = "9e74add"
4+
const EXPECTED_WEAVIATE_VERSION = "1.16.5"
5+
const EXPECTED_WEAVIATE_GIT_HASH = "438e826"
66

77
describe("cluster nodes endpoint", () => {
88
const client = weaviate.client({

connection/auth.js

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
export class Authenticator {
2+
constructor(http, creds) {
3+
this.http = http;
4+
this.creds = creds;
5+
this.bearerToken = "";
6+
this.refreshToken = "";
7+
this.expirationEpoch = 0
8+
this.refreshRunning = false;
9+
10+
// If the authentication method is access token,
11+
// our bearer token is already available for use
12+
if (this.creds instanceof AuthAccessTokenCredentials) {
13+
this.bearerToken = this.creds.accessToken;
14+
this.expirationEpoch = calcExpirationEpoch(this.creds.expiresIn);
15+
this.refreshToken = this.creds.refreshToken;
16+
}
17+
}
18+
19+
refresh = async (localConfig) => {
20+
var config = await this.getOpenidConfig(localConfig);
21+
22+
var authenticator;
23+
switch (this.creds.constructor) {
24+
case AuthUserPasswordCredentials:
25+
authenticator = new UserPasswordAuthenticator(this.http, this.creds, config);
26+
break;
27+
case AuthAccessTokenCredentials:
28+
authenticator = new AccessTokenAuthenticator(this.http, this.creds, config);
29+
break;
30+
default:
31+
throw new Error("unsupported credential type");
32+
}
33+
34+
return authenticator.refresh()
35+
.then(resp => {
36+
this.bearerToken = resp.bearerToken;
37+
this.expirationEpoch = resp.expirationEpoch;
38+
this.refreshToken = resp.refreshToken;
39+
if (!this.refreshRunning) {
40+
this.runBackgroundTokenRefresh(authenticator);
41+
this.refreshRunning = true;
42+
}
43+
});
44+
};
45+
46+
getOpenidConfig = async (localConfig) => {
47+
return this.http.externalGet(localConfig.href)
48+
.then(openidProviderConfig => {
49+
return {
50+
clientId: localConfig.clientId,
51+
provider: openidProviderConfig
52+
};
53+
});
54+
};
55+
56+
runBackgroundTokenRefresh = (authenticator) => {
57+
setInterval(async () => {
58+
// check every 30s if the token will expire in <= 1m,
59+
// if so, refresh
60+
if (this.expirationEpoch - Date.now() <= 60_000) {
61+
var resp = await authenticator.refresh();
62+
this.bearerToken = resp.bearerToken;
63+
this.expirationEpoch = resp.expirationEpoch;
64+
this.refreshToken = resp.refreshToken;
65+
}
66+
}, 30_000)
67+
};
68+
}
69+
70+
export class AuthUserPasswordCredentials {
71+
constructor(creds) {
72+
this.username = creds.username;
73+
this.password = creds.password;
74+
}
75+
}
76+
77+
class UserPasswordAuthenticator {
78+
constructor(http, creds, config) {
79+
this.http = http;
80+
this.creds = creds;
81+
this.openidConfig = config;
82+
}
83+
84+
refresh = () => {
85+
this.validateOpenidConfig();
86+
return this.requestAccessToken()
87+
.then(tokenResp => {
88+
return {
89+
bearerToken: tokenResp.access_token,
90+
expirationEpoch: calcExpirationEpoch(tokenResp.expires_in),
91+
refreshToken: tokenResp.refresh_token
92+
};
93+
})
94+
.catch(err => {
95+
return Promise.reject(
96+
new Error(`failed to refresh access token: ${err}`)
97+
);
98+
});
99+
};
100+
101+
requestAccessToken = () => {
102+
var url = this.openidConfig.provider.token_endpoint;
103+
var params = new URLSearchParams({
104+
grant_type: "password",
105+
client_id: this.openidConfig.clientId,
106+
username: this.creds.username,
107+
password: this.creds.password,
108+
scope: "openid offline_access"
109+
});
110+
let contentType = "application/x-www-form-urlencoded;charset=UTF-8";
111+
return this.http.externalPost(url, params, contentType);
112+
};
113+
114+
validateOpenidConfig = () => {
115+
if (this.openidConfig.provider.grant_types_supported !== undefined &&
116+
!this.openidConfig.provider.grant_types_supported.includes("password")) {
117+
throw new Error("grant_type password not supported");
118+
}
119+
if (this.openidConfig.provider.token_endpoint.includes(
120+
"https://login.microsoftonline.com")) {
121+
throw new Error("microsoft/azure recommends to avoid authentication using "+
122+
"username and password, so this method is not supported by this client");
123+
}
124+
};
125+
}
126+
127+
export class AuthAccessTokenCredentials {
128+
constructor(creds) {
129+
this.validate(creds);
130+
this.accessToken = creds.accessToken;
131+
this.expirationEpoch = calcExpirationEpoch(creds.expiresIn);
132+
this.refreshToken = creds.refreshToken;
133+
}
134+
135+
validate = (creds) => {
136+
if (creds.expiresIn === undefined) {
137+
throw new Error("AuthAccessTokenCredentials: expiresIn is required");
138+
}
139+
if (!Number.isInteger(creds.expiresIn) || creds.expiresIn <= 0) {
140+
throw new Error("AuthAccessTokenCredentials: expiresIn must be int > 0");
141+
}
142+
};
143+
}
144+
145+
class AccessTokenAuthenticator {
146+
constructor(http, creds, config) {
147+
this.http = http;
148+
this.creds = creds;
149+
this.openidConfig = config;
150+
}
151+
152+
refresh = () => {
153+
if (this.creds.refreshToken === undefined || this.creds.refreshToken == "") {
154+
console.warn("AuthAccessTokenCredentials not provided with refreshToken, cannot refresh");
155+
return Promise.resolve({
156+
bearerToken: this.creds.accessToken,
157+
expirationEpoch: this.creds.expirationEpoch
158+
});
159+
}
160+
this.validateOpenidConfig();
161+
return this.requestAccessToken()
162+
.then(tokenResp => {
163+
return {
164+
bearerToken: tokenResp.access_token,
165+
expirationEpoch: calcExpirationEpoch(tokenResp.expires_in),
166+
refreshToken: tokenResp.refresh_token
167+
};
168+
})
169+
.catch(err => {
170+
return Promise.reject(
171+
new Error(`failed to refresh access token: ${err}`)
172+
);
173+
});
174+
};
175+
176+
validateOpenidConfig = () => {
177+
if (this.openidConfig.provider.grant_types_supported === undefined ||
178+
!this.openidConfig.provider.grant_types_supported.includes("refresh_token")) {
179+
throw new Error("grant_type refresh_token not supported");
180+
}
181+
};
182+
183+
requestAccessToken = () => {
184+
var url = this.openidConfig.provider.token_endpoint;
185+
var params = new URLSearchParams({
186+
grant_type: "refresh_token",
187+
client_id: this.openidConfig.clientId,
188+
refresh_token: this.creds.refreshToken,
189+
});
190+
let contentType = "application/x-www-form-urlencoded;charset=UTF-8";
191+
return this.http.externalPost(url, params, contentType);
192+
};
193+
}
194+
195+
function calcExpirationEpoch(expiresIn) {
196+
return Date.now() + ((expiresIn - 2) * 1000) // -2 for some lag
197+
}

connection/gqlClient.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
const client = (config) => {
2+
const scheme = config.scheme
3+
const host = config.host
4+
const defaultHeaders = config.headers
5+
return {
6+
query: (query, headers = {}) => {
7+
var gql = require("graphql-client")({
8+
url: `${scheme}://${host}/v1/graphql`,
9+
headers: {
10+
...defaultHeaders,
11+
...headers,
12+
}
13+
});
14+
return gql.query(query);
15+
}
16+
}
17+
}
18+
19+
module.exports = client;

0 commit comments

Comments
 (0)