From c656ab775cf24e7c7b5512f211b902963c5fa607 Mon Sep 17 00:00:00 2001 From: Parker Duckworth Date: Fri, 24 Mar 2023 16:24:08 -0500 Subject: [PATCH 1/2] explicit support for api key authentication --- ci/docker-compose-wcs.yml | 3 + src/connection/auth.ts | 32 +++++-- src/connection/index.ts | 40 ++++++-- src/connection/journey.test.ts | 48 +++++++++- src/connection/unit.test.ts | 170 ++++++++++++++++++--------------- src/index.ts | 2 + 6 files changed, 200 insertions(+), 95 deletions(-) diff --git a/ci/docker-compose-wcs.yml b/ci/docker-compose-wcs.yml index 4367bda5..c778f88a 100644 --- a/ci/docker-compose-wcs.yml +++ b/ci/docker-compose-wcs.yml @@ -25,4 +25,7 @@ services: AUTHORIZATION_ADMINLIST_ENABLED: 'true' AUTHORIZATION_ADMINLIST_USERS: 'ms_2d0e007e7136de11d5f29fce7a53dae219a51458@existiert.net' AUTHENTICATION_OIDC_SCOPES: 'openid,email' + AUTHENTICATION_APIKEY_ENABLED: 'true' + AUTHENTICATION_APIKEY_ALLOWED_KEYS: 'my-secret-key' + AUTHENTICATION_APIKEY_USERS: 'ms_2d0e007e7136de11d5f29fce7a53dae219a51458@existiert.net' ... diff --git a/src/connection/auth.ts b/src/connection/auth.ts index 279af2eb..71101844 100644 --- a/src/connection/auth.ts +++ b/src/connection/auth.ts @@ -6,11 +6,11 @@ interface AuthenticatorResult { refreshToken: string; } -interface OIDCAuthFlow { +export interface OidcAuthFlow { refresh: () => Promise; } -export class Authenticator { +export class OidcAuthenticator { private readonly http: HttpClient; private readonly creds: any; private accessToken: string; @@ -38,7 +38,7 @@ export class Authenticator { refresh = async (localConfig: any) => { const config = await this.getOpenidConfig(localConfig); - let authenticator: OIDCAuthFlow; + let authenticator: OidcAuthFlow; switch (this.creds.constructor) { case AuthUserPasswordCredentials: authenticator = new UserPasswordAuthenticator( @@ -105,6 +105,18 @@ export class Authenticator { refreshTokenProvided = () => { return this.refreshToken && this.refreshToken != ''; }; + + getAccessToken = () => { + return this.accessToken; + }; + + getExpiresAt = () => { + return this.expiresAt; + }; + + resetExpiresAt() { + this.expiresAt = 0; + } } export interface UserPasswordCredentialsInput { @@ -130,7 +142,7 @@ interface RequestAccessTokenResponse { refresh_token: string; } -class UserPasswordAuthenticator implements OIDCAuthFlow { +class UserPasswordAuthenticator implements OidcAuthFlow { private creds: any; private http: any; private openidConfig: any; @@ -222,7 +234,7 @@ export class AuthAccessTokenCredentials { }; } -class AccessTokenAuthenticator implements OIDCAuthFlow { +class AccessTokenAuthenticator implements OidcAuthFlow { private creds: any; private http: any; private openidConfig: any; @@ -299,7 +311,7 @@ export class AuthClientCredentials { } } -class ClientCredentialsAuthenticator implements OIDCAuthFlow { +class ClientCredentialsAuthenticator implements OidcAuthFlow { private creds: any; private http: any; private openidConfig: any; @@ -357,6 +369,14 @@ class ClientCredentialsAuthenticator implements OIDCAuthFlow { }; } +export class ApiKey { + public readonly apiKey: string; + + constructor(apiKey: string) { + this.apiKey = apiKey; + } +} + function calcExpirationEpoch(expiresIn: number): number { return Date.now() + (expiresIn - 2) * 1000; // -2 for some lag } diff --git a/src/connection/index.ts b/src/connection/index.ts index 2731852c..67d4211b 100644 --- a/src/connection/index.ts +++ b/src/connection/index.ts @@ -1,4 +1,4 @@ -import { Authenticator } from './auth'; +import { ApiKey, OidcAuthenticator } from './auth'; import OpenidConfigurationGetter from '../misc/openidConfigurationGetter'; import httpClient, { HttpClient } from './httpClient'; @@ -6,19 +6,33 @@ import gqlClient, { GraphQLClient } from './gqlClient'; import { ConnectionParams } from '../index'; export default class Connection { - public readonly auth: any; - private readonly authEnabled: boolean; + private apiKey?: string; + private oidcAuth?: OidcAuthenticator; + private authEnabled: boolean; private gql: GraphQLClient; public readonly http: HttpClient; constructor(params: ConnectionParams) { this.http = httpClient(params); this.gql = gqlClient(params); + this.authEnabled = this.parseAuthParams(params); + } - this.authEnabled = params.authClientSecret !== undefined; - if (this.authEnabled) { - this.auth = new Authenticator(this.http, params.authClientSecret); + private parseAuthParams(params: ConnectionParams): boolean { + if (params.authClientSecret && params.apiKey) { + throw new Error( + 'must provide one of authClientSecret (OIDC) or apiKey, cannot provide both' + ); + } + if (params.authClientSecret) { + this.oidcAuth = new OidcAuthenticator(this.http, params.authClientSecret); + return true; + } + if (params.apiKey) { + this.apiKey = params.apiKey?.apiKey; + return true; } + return false; } post = (path: string, payload: any, expectReturnContent = true) => { @@ -84,6 +98,14 @@ export default class Connection { }; login = async () => { + if (this.apiKey) { + return this.apiKey; + } + + if (!this.oidcAuth) { + return ''; + } + const localConfig = await new OpenidConfigurationGetter(this.http).do(); if (localConfig === undefined) { @@ -93,9 +115,9 @@ export default class Connection { return ''; } - if (Date.now() >= this.auth.expiresAt) { - await this.auth.refresh(localConfig); + if (Date.now() >= this.oidcAuth.getExpiresAt()) { + await this.oidcAuth.refresh(localConfig); } - return this.auth.accessToken; + return this.oidcAuth.getAccessToken(); }; } diff --git a/src/connection/journey.test.ts b/src/connection/journey.test.ts index 9965d153..4f6df3d2 100644 --- a/src/connection/journey.test.ts +++ b/src/connection/journey.test.ts @@ -1,4 +1,5 @@ import { + ApiKey, AuthAccessTokenCredentials, AuthClientCredentials, AuthUserPasswordCredentials, @@ -151,6 +152,24 @@ describe('connection', () => { }); }); + it('makes a logged-in request with API key', () => { + const client = weaviate.client({ + scheme: 'http', + host: 'localhost:8085', + apiKey: new ApiKey('my-secret-key'), + }); + + return client.misc + .metaGetter() + .do() + .then((res: any) => { + expect(res.version).toBeDefined(); + }) + .catch((e: any) => { + throw new Error('it should not have errord: ' + e); + }); + }); + it('makes a logged-in request with access token', async () => { if ( process.env.WCS_DUMMY_CI_PW == undefined || @@ -172,11 +191,12 @@ describe('connection', () => { // use it to test AuthAccessTokenCredentials await dummy.login(); + const accessToken = (dummy as any).oidcAuth?.accessToken || ''; const client = weaviate.client({ scheme: 'http', host: 'localhost:8085', authClientSecret: new AuthAccessTokenCredentials({ - accessToken: dummy.auth.accessToken, + accessToken: accessToken, expiresIn: 900, }), }); @@ -213,17 +233,18 @@ describe('connection', () => { // use it to test AuthAccessTokenCredentials await dummy.login(); + const accessToken = (dummy as any).oidcAuth?.accessToken || ''; const conn = new Connection({ scheme: 'http', host: 'localhost:8085', authClientSecret: new AuthAccessTokenCredentials({ - accessToken: dummy.auth.accessToken, + accessToken: accessToken, expiresIn: 1, - refreshToken: dummy.auth.refreshToken, + refreshToken: (dummy as any).oidcAuth?.refreshToken, }), }); // force the use of refreshToken - conn.auth.expiresAt = 0; + (conn as any).oidcAuth?.resetExpiresAt(); return conn .login() @@ -293,7 +314,7 @@ describe('connection', () => { }), }); // force the use of refreshToken - conn.auth.expiresAt = 0; + (conn as any).oidcAuth?.resetExpiresAt(); await conn .login() @@ -309,4 +330,21 @@ describe('connection', () => { 'AuthAccessTokenCredentials not provided with refreshToken, cannot refresh' ); }); + + it('fails to create client with both OIDC creds and API key set', () => { + expect(() => { + // eslint-disable-next-line no-new + new Connection({ + scheme: 'http', + host: 'localhost:8085', + authClientSecret: new AuthAccessTokenCredentials({ + accessToken: 'abcd1234', + expiresIn: 1, + }), + apiKey: new ApiKey('some-key'), + }); + }).toThrow( + 'must provide one of authClientSecret (OIDC) or apiKey, cannot provide both' + ); + }); }); diff --git a/src/connection/unit.test.ts b/src/connection/unit.test.ts index d3775c2f..2735d0c1 100644 --- a/src/connection/unit.test.ts +++ b/src/connection/unit.test.ts @@ -3,108 +3,128 @@ import { AuthClientCredentials, AuthUserPasswordCredentials, AuthAccessTokenCredentials, + ApiKey, } from './auth'; import Connection from './index'; describe('mock server auth tests', () => { const server = testServer(); + describe('OIDC auth flows', () => { + it('should login with client_credentials grant', async () => { + const conn = new Connection({ + scheme: 'http', + host: 'localhost:' + server.port, + authClientSecret: new AuthClientCredentials({ + clientSecret: 'supersecret', + scopes: ['some_scope'], + }), + }); - it('should login with client_credentials grant', async () => { - const conn = new Connection({ - scheme: 'http', - host: 'localhost:' + server.port, - authClientSecret: new AuthClientCredentials({ - clientSecret: 'supersecret', - scopes: ['some_scope'], - }), + await conn + .login() + .then((token) => { + expect(token).toEqual('access_token_000'); + expect((conn as any).oidcAuth?.refreshToken).toEqual( + 'refresh_token_000' + ); + expect((conn as any).oidcAuth?.expiresAt).toBeGreaterThan(Date.now()); + }) + .catch((e) => { + throw new Error('it should not have failed: ' + e); + }); + + const request = server.lastRequest(); + + expect(request.body).toEqual({ + client_id: 'client123', + client_secret: 'supersecret', + grant_type: 'client_credentials', + scope: 'some_scope', + }); }); - await conn - .login() - .then((token) => { - expect(token).toEqual('access_token_000'); - expect(conn.auth.refreshToken).toEqual('refresh_token_000'); - expect(conn.auth.expiresAt).toBeGreaterThan(Date.now()); - }) - .catch((e) => { - throw new Error('it should not have failed: ' + e); + it('should login with password grant', async () => { + const conn = new Connection({ + scheme: 'http', + host: 'localhost:' + server.port, + authClientSecret: new AuthUserPasswordCredentials({ + username: 'user123', + password: 'secure_password', + scopes: ['custom_scope'], + }), }); - const request = server.lastRequest(); + await conn + .login() + .then((token) => { + expect(token).toEqual('access_token_000'); + expect((conn as any).oidcAuth?.refreshToken).toEqual( + 'refresh_token_000' + ); + expect((conn as any).oidcAuth?.expiresAt).toBeGreaterThan(Date.now()); + }) + .catch((e) => { + throw new Error('it should not have failed: ' + e); + }); - expect(request.body).toEqual({ - client_id: 'client123', - client_secret: 'supersecret', - grant_type: 'client_credentials', - scope: 'some_scope', - }); - }); + const request = server.lastRequest(); - it('should login with password grant', async () => { - const conn = new Connection({ - scheme: 'http', - host: 'localhost:' + server.port, - authClientSecret: new AuthUserPasswordCredentials({ + expect(request.body).toEqual({ username: 'user123', password: 'secure_password', - scopes: ['custom_scope'], - }), + grant_type: 'password', + client_id: 'client123', + scope: 'custom_scope offline_access', + }); }); - await conn - .login() - .then((token) => { - expect(token).toEqual('access_token_000'); - expect(conn.auth.refreshToken).toEqual('refresh_token_000'); - expect(conn.auth.expiresAt).toBeGreaterThan(Date.now()); - }) - .catch((e) => { - throw new Error('it should not have failed: ' + e); + it('should login with refresh_token grant', async () => { + const conn = new Connection({ + scheme: 'http', + host: 'localhost:' + server.port, + authClientSecret: new AuthAccessTokenCredentials({ + accessToken: 'old-access-token', + expiresIn: 1, + refreshToken: 'old-refresh-token', + }), }); - const request = server.lastRequest(); + // force the use of refreshToken + (conn as any).oidcAuth?.resetExpiresAt(); + + await conn + .login() + .then((token) => { + expect(token).toEqual('access_token_000'); + expect((conn as any).oidcAuth?.refreshToken).toEqual( + 'refresh_token_000' + ); + expect((conn as any).oidcAuth?.expiresAt).toBeGreaterThan(Date.now()); + }) + .catch((e) => { + throw new Error('it should not have failed: ' + e); + }); - expect(request.body).toEqual({ - username: 'user123', - password: 'secure_password', - grant_type: 'password', - client_id: 'client123', - scope: 'custom_scope offline_access', + const request = server.lastRequest(); + + expect(request.body).toEqual({ + client_id: 'client123', + grant_type: 'refresh_token', + refresh_token: 'old-refresh-token', + }); }); }); - it('should login with refresh_token grant', async () => { + it('should login with API key', async () => { + const apiKey = 'abcd123'; + const conn = new Connection({ scheme: 'http', host: 'localhost:' + server.port, - authClientSecret: new AuthAccessTokenCredentials({ - accessToken: 'old-access-token', - expiresIn: 1, - refreshToken: 'old-refresh-token', - }), + apiKey: new ApiKey(apiKey), }); - // force the use of refreshToken - conn.auth.expiresAt = 0; - - await conn - .login() - .then((token) => { - expect(token).toEqual('access_token_000'); - expect(conn.auth.refreshToken).toEqual('refresh_token_000'); - expect(conn.auth.expiresAt).toBeGreaterThan(Date.now()); - }) - .catch((e) => { - throw new Error('it should not have failed: ' + e); - }); - - const request = server.lastRequest(); - - expect(request.body).toEqual({ - client_id: 'client123', - grant_type: 'refresh_token', - refresh_token: 'old-refresh-token', - }); + await conn.login().then((key) => expect(key).toEqual(apiKey)); }); it('shuts down the server', () => { diff --git a/src/index.ts b/src/index.ts index c98f8987..af7edaf9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import cluster, { Cluster } from './cluster/index'; import clusterConsts from './cluster/consts'; import replicationConsts from './data/replication/consts'; import { + ApiKey, AuthAccessTokenCredentials, AuthClientCredentials, AuthUserPasswordCredentials, @@ -27,6 +28,7 @@ export interface ConnectionParams { | AuthClientCredentials | AuthAccessTokenCredentials | AuthUserPasswordCredentials; + apiKey?: ApiKey; host: string; scheme: string; headers?: HeadersInit; From 786cbbecd59419115819b74511566ea77486d35e Mon Sep 17 00:00:00 2001 From: Parker Duckworth Date: Fri, 24 Mar 2023 16:34:27 -0500 Subject: [PATCH 2/2] add ApiKey to default export and examples --- examples/javascript/index.js | 2 ++ examples/typescript/index.ts | 2 ++ src/index.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/examples/javascript/index.js b/examples/javascript/index.js index bfb205bc..094d547e 100644 --- a/examples/javascript/index.js +++ b/examples/javascript/index.js @@ -31,6 +31,8 @@ console.log( ) ); +console.log(JSON.stringify(new weaviate.ApiKey('abcd1234'))); + console.log(weaviate.backup.Backend.GCS); console.log(weaviate.batch.DeleteOutput.MINIMAL); console.log(weaviate.cluster.NodeStatus.HEALTHY); diff --git a/examples/typescript/index.ts b/examples/typescript/index.ts index 97a08b0d..e1dde730 100644 --- a/examples/typescript/index.ts +++ b/examples/typescript/index.ts @@ -31,6 +31,8 @@ console.log( ) ); +console.log(JSON.stringify(new weaviate.ApiKey('abcd1234'))); + console.log(weaviate.backup.Backend.GCS); console.log(weaviate.batch.DeleteOutput.MINIMAL); console.log(weaviate.cluster.NodeStatus.HEALTHY); diff --git a/src/index.ts b/src/index.ts index af7edaf9..fb266a20 100644 --- a/src/index.ts +++ b/src/index.ts @@ -89,6 +89,7 @@ const app = { cluster: clusterConsts, replication: replicationConsts, + ApiKey, AuthUserPasswordCredentials, AuthAccessTokenCredentials, AuthClientCredentials,