From d163d54a9ddd9525133318dd514add48487cd608 Mon Sep 17 00:00:00 2001 From: huwshimi Date: Fri, 14 Jun 2024 09:38:05 +1000 Subject: [PATCH] feat: support OIDC login (#127) * feat: support OIDC login --- api/client.ts | 49 ++++++++++++++++------- api/custom-facades/AdminV4.ts | 47 ++++++++++++++++++++++ api/tests/test-client.ts | 73 +++++++++++++++++++++++++++++------ 3 files changed, 142 insertions(+), 27 deletions(-) create mode 100644 api/custom-facades/AdminV4.ts diff --git a/api/client.ts b/api/client.ts index 9267232d8..ba0e30f93 100644 --- a/api/client.ts +++ b/api/client.ts @@ -36,6 +36,7 @@ import { GenericFacade, } from "./types.js"; import { createAsyncHandler, toError } from "./utils.js"; +import AdminV4 from "./custom-facades/AdminV4.js"; export const CLIENT_VERSION = "3.3.2"; @@ -44,6 +45,7 @@ export interface ConnectOptions { closeCallback: CloseCallback; debug?: boolean; facades?: (ClassType | GenericFacade)[]; + oidcEnabled?: boolean; onWSCreated?: (ws: WebSocket) => void; wsclass?: typeof WebSocket; } @@ -56,11 +58,16 @@ export interface ConnectionInfo { getFacade?: (name: string) => Facade; } -export interface Credentials { - username?: string; - password?: string; - macaroons?: MacaroonObject[][]; -} +export type ExcludeProps = Partial>; + +export type Credentials = + | { + username: string; + password: string; + } + | { + macaroons: MacaroonObject[][]; + }; // The type of a Macaroon from the Admin facade does not match a real macaroon. const isMacaroonObject = ( @@ -171,8 +178,8 @@ function connect( */ async function connectAndLogin( url: string, - credentials: Credentials, options: ConnectOptions, + credentials?: Credentials, clientVersion = CLIENT_VERSION ): Promise<{ conn?: Connection; @@ -206,8 +213,8 @@ async function connectAndLogin( }; return await connectAndLogin( generateURL(url, srv), - credentials, options, + credentials, clientVersion ); } @@ -240,7 +247,8 @@ class Client { _transport: Transport; _bakery?: Bakery | null; _facades: (ClassType | GenericFacade)[]; - _admin: AdminV3; + _admin: AdminV3 | AdminV4; + _oidcEnabled: boolean; constructor(ws: WebSocket, options: ConnectOptions) { // Instantiate the transport, used for sending messages to the server. @@ -250,9 +258,13 @@ class Client { Boolean(options.debug) ); + this._oidcEnabled = options.oidcEnabled || false; this._facades = options.facades || []; this._bakery = options.bakery; - this._admin = new AdminV3(this._transport, {}); + this._admin = new (this._oidcEnabled ? AdminV4 : AdminV3)( + this._transport, + {} + ); } /** @@ -269,7 +281,7 @@ class Client { promise will not be resolved or rejected if a callback is provided. */ async login( - credentials: Credentials, + credentials?: Credentials, clientVersion = CLIENT_VERSION ): Promise { const args: LoginRequest = { @@ -283,10 +295,10 @@ class Client { const url = this._transport._ws.url; const origin = url; - if (credentials.username && credentials.password) { + if (credentials && "username" in credentials) { args.credentials = credentials.password; args["auth-tag"] = `user-${credentials.username}`; - } else { + } else if (credentials && "macaroons" in credentials) { const macaroons = this._bakery?.storage.get(origin); let deserialized; if (macaroons) { @@ -300,7 +312,10 @@ class Client { let response: LoginResult | null = null; try { try { - response = await this._admin.login(args); + response = + this._oidcEnabled && "loginWithSessionCookie" in this._admin + ? await this._admin.loginWithSessionCookie() + : await this._admin.login(args); } catch (error) { if ( error instanceof Error && @@ -337,8 +352,12 @@ class Client { const serialized = btoa(JSON.stringify(macaroons)); this._bakery?.storage.set(origin, serialized, () => {}); // Send the login request again including the discharge macaroons. - credentials.macaroons = [macaroons]; - return resolve(this.login(credentials, clientVersion)); + return resolve( + this.login( + { ...credentials, macaroons: [macaroons] }, + clientVersion + ) + ); }; const onFailure = (err: string | MacaroonError) => { reject( diff --git a/api/custom-facades/AdminV4.ts b/api/custom-facades/AdminV4.ts new file mode 100644 index 000000000..b3a5e858f --- /dev/null +++ b/api/custom-facades/AdminV4.ts @@ -0,0 +1,47 @@ +/** + Juju Admin version 4. + This facade is available on: + Controller-machine-agent + Machine-agent + Unit-agent + Controllers + Models +*/ + +import type { JujuRequest } from "../../generator/interfaces.js"; +import { ConnectionInfo, Transport } from "../client.js"; +import AdminV3, { LoginResult } from "../facades/admin/AdminV3.js"; + +/** + admin is the only object that unlogged-in clients can access. It holds any + methods that are needed to log in. +*/ +class AdminV4 extends AdminV3 { + static NAME = "Admin"; + static VERSION = 4; + + NAME = "Admin"; + VERSION = 4; + + constructor(transport: Transport, info: ConnectionInfo) { + super(transport, info); + } + /** + LoginWithSessionCookie logs in if the session cookie exists when the + websocket connection is made. All subsequent requests on the + connection will act as the authenticated user. + */ + loginWithSessionCookie(): Promise { + return new Promise((resolve, reject) => { + const req: JujuRequest = { + type: "Admin", + request: "LoginWithSessionCookie", + version: 4, + }; + + this._transport.write(req, resolve, reject); + }); + } +} + +export default AdminV4; diff --git a/api/tests/test-client.ts b/api/tests/test-client.ts index 23c5673f4..037b824d5 100644 --- a/api/tests/test-client.ts +++ b/api/tests/test-client.ts @@ -163,7 +163,7 @@ describe("connect", () => { it("login redirection error failure via promise", (done) => { connect("wss://1.2.3.4", options).then((juju: Client) => { juju - ?.login({}) + ?.login({ macaroons: [] }) .then(() => fail) .catch((error) => { validateRedirectionLoginFailure(error); @@ -206,7 +206,7 @@ describe("connect", () => { it("login generic redirection error failure via promise", (done) => { connect("wss://1.2.3.4", options).then((juju: Client) => { juju - ?.login({}) + ?.login({ macaroons: [] }) .then(() => fail) .catch((error) => { expect(error).toStrictEqual(new Error("bad wolf")); @@ -253,7 +253,7 @@ describe("connect", () => { expect(err).toBe(null); expect(juju).not.toBe(null); juju - ?.login({}) + ?.login({ macaroons: [] }) .then(() => fail) .catch((error) => { validateRedirectionLoginSuccess(juju, error); @@ -298,7 +298,7 @@ describe("connect", () => { it("login redirection error success via promises", (done) => { connect("wss://1.2.3.4", options).then((juju: Client) => { juju - ?.login({}) + ?.login({ macaroons: [] }) .then(() => fail) .catch((error) => { validateRedirectionLoginSuccess(juju, error); @@ -534,6 +534,31 @@ describe("connect", () => { ws.open(); }); + it("connect and enable OIDC login", (done) => { + connect( + "wss://1.2.3.4", + { + ...options, + oidcEnabled: true, + }, + (err?: CallbackError, juju?: Client) => { + expect(err).toBe(null); + juju?.login().then(() => { + requestEqual(ws.lastRequest, { + type: "Admin", + request: "LoginWithSessionCookie", + version: 4, + }); + done(); + }); + // Reply to the login request. + ws.reply({ response: {} }); + } + ); + // Open the WebSocket connection. + ws.open(); + }); + it("connection transport success", (done) => { const options = { closeCallback: jest.fn() }; makeConnection(options, (conn, ws) => { @@ -644,8 +669,8 @@ describe("connectAndLogin", () => { }; it("connect failure", (done) => { - const creds = {}; - connectAndLogin(url, creds, options).catch((error) => { + const creds = { macaroons: [] }; + connectAndLogin(url, options, creds).catch((error) => { expect(error).toStrictEqual( new Error("cannot connect WebSocket: bad wolf") ); @@ -656,8 +681,8 @@ describe("connectAndLogin", () => { }); it("login redirection error failure", (done) => { - const creds = { user: "who", password: "tardis" }; - connectAndLogin(url, creds, options) + const creds = { username: "who", password: "tardis" }; + connectAndLogin(url, options, creds) .then(() => fail) .catch((error) => { expect(error.message).toBe("cannot connect to model after redirection"); @@ -684,7 +709,7 @@ describe("connectAndLogin", () => { it("login redirection error success", (done) => { // If this test is timing out then check that the setTimeout is opening the // model websocket. - const creds = { user: "who", password: "tardis" }; + const creds = { username: "who", password: "tardis" }; let modelWS: MockWebSocket; const options = { bakery: makeBakery(true), @@ -748,7 +773,7 @@ describe("connectAndLogin", () => { } }, }; - connectAndLogin(url, creds, options).then( + connectAndLogin(url, options, creds).then( (result?: { conn?: Connection; logout: Client["logout"] }) => { expect(result).not.toBe(null); expect(result?.conn).not.toBe(null); @@ -762,8 +787,8 @@ describe("connectAndLogin", () => { }); it("login success", (done) => { - const creds = { user: "who", password: "tardis" }; - connectAndLogin(url, creds, options).then((result: any) => { + const creds = { username: "who", password: "tardis" }; + connectAndLogin(url, options, creds).then((result: any) => { expect(result).not.toBe(null); expect(result.conn).not.toBe(null); expect(result.logout).not.toBe(null); @@ -781,6 +806,30 @@ describe("connectAndLogin", () => { // Open the WebSocket connection. ws.open(); }); + + it("login success", (done) => { + connectAndLogin(url, { ...options, oidcEnabled: true }).then( + (result: any) => { + result.logout(); + // The WebSocket is now closed. + expect(ws.readyState).toBe(3); + requestEqual(ws.lastRequest, { + type: "Admin", + request: "LoginWithSessionCookie", + version: 4, + }); + done(); + } + ); + ws.queueResponses( + new Map([ + // Reply to the login request. + [1, { response: { facades: [] } }], + ]) + ); + // Open the WebSocket connection. + ws.open(); + }); }); describe("generateModelURL", () => {