diff --git a/CHANGES.md b/CHANGES.md index 1afdcee..9cb311e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,10 +1,18 @@ ## Changes in version 0.1.2 (in development) + ### Enhancements - When generating titles for input fields without a title set, we skip a prefix `x-` from the input's name before converting it. (#74) + ### Fixes +- Custom Service now recognizes the renamed Cuiman service options: + `loginUrl`, `tokenUrl`, `grantType`, `accessToken`, and + `accessTokenHeader`. It applies supplied Basic, access-token, and API-key + credentials to service requests, and uses a supplied OAuth2 access token + without starting a browser authorization-code flow. + - Map process inputs now zoom to the current polygon or bbox when an existing value is loaded, so saved geometry is visible immediately. diff --git a/src/config/bootstrap.test.ts b/src/config/bootstrap.test.ts index 6feb00d..17c0db0 100644 --- a/src/config/bootstrap.test.ts +++ b/src/config/bootstrap.test.ts @@ -83,6 +83,44 @@ describe("parseAppBootstrapConfig", () => { warn.mockRestore(); }); + it("preserves the renamed Cuiman authentication options", () => { + const encodedService = encodeBase64Url({ + id: "client", + meta: { type: "custom", title: "Client" }, + options: { + apiUrl: "https://example.test/ogcapi", + authType: "oauth2", + tokenUrl: "https://auth.example.test/token", + grantType: "client_credentials", + accessToken: "secret", + accessTokenHeader: "X-Service-Token", + useBearer: false, + }, + }); + + expect( + parseAppBootstrapConfig(`?service=${encodedService}`).service, + ).toEqual({ + id: "client", + meta: { + type: "custom", + title: "Client", + description: undefined, + disabled: undefined, + hidden: undefined, + }, + options: { + apiUrl: "https://example.test/ogcapi", + authType: "oauth2", + tokenUrl: "https://auth.example.test/token", + grantType: "client_credentials", + accessToken: "secret", + accessTokenHeader: "X-Service-Token", + useBearer: false, + }, + }); + }); + it("ignores invalid color scheme values", () => { expect(parseAppBootstrapConfig("?scheme=auto")).toEqual({ compact: false, diff --git a/src/service/providers/custom.test.ts b/src/service/providers/custom.test.ts index 839fad1..b09d832 100644 --- a/src/service/providers/custom.test.ts +++ b/src/service/providers/custom.test.ts @@ -70,7 +70,7 @@ describe("CustomServiceProvider", () => { const service = await provider.createService({ apiUrl: "https://example.com/api/", authType: "token", - token: "secret", + accessToken: "secret", useBearer: true, }); @@ -87,8 +87,8 @@ describe("CustomServiceProvider", () => { const service = await provider.createService({ apiUrl: "https://example.com/api/", authType: "token", - token: "secret", - tokenHeader: "X-Custom-Token", + accessToken: "secret", + accessTokenHeader: "X-Custom-Token", }); expect(loadServiceRootMetadata).toHaveBeenCalledWith( @@ -104,7 +104,7 @@ describe("CustomServiceProvider", () => { const service = await provider.createService({ apiUrl: "https://example.com/api/", authType: "token", - token: "secret", + accessToken: "secret", }); expect(loadServiceRootMetadata).toHaveBeenCalledWith( @@ -114,19 +114,59 @@ describe("CustomServiceProvider", () => { expect(service.defaultHeaders).toEqual({ "X-Auth-Token": "secret" }); }); - it("does not pass auth headers for other auth types", async () => { + it("uses an OAuth2 access token without starting a browser login flow", async () => { const provider = new CustomServiceProvider(); const service = await provider.createService({ apiUrl: "https://example.com/api/", - authType: "none", - token: "secret", + authType: "oauth2", + tokenUrl: "https://auth.example.test/token", + grantType: "client_credentials", + accessToken: "secret", + useBearer: false, + accessTokenHeader: "X-Service-Token", }); expect(loadServiceRootMetadata).toHaveBeenCalledWith( "https://example.com/api/", - {}, + { "X-Service-Token": "secret" }, ); - expect(service.defaultHeaders).toEqual({}); + expect(service.defaultHeaders).toEqual({ "X-Service-Token": "secret" }); + }); + + it("passes basic auth headers to the URL service", async () => { + const provider = new CustomServiceProvider(); + + const service = await provider.createService({ + apiUrl: "https://example.com/api/", + authType: "basic", + username: "user", + password: "secret", + }); + + expect(loadServiceRootMetadata).toHaveBeenCalledWith( + "https://example.com/api/", + { Authorization: "Basic dXNlcjpzZWNyZXQ=" }, + ); + expect(service.defaultHeaders).toEqual({ + Authorization: "Basic dXNlcjpzZWNyZXQ=", + }); + }); + + it("passes API key auth headers to the URL service", async () => { + const provider = new CustomServiceProvider(); + + const service = await provider.createService({ + apiUrl: "https://example.com/api/", + authType: "api-key", + apiKey: "secret", + apiKeyHeader: "X-Service-Key", + }); + + expect(loadServiceRootMetadata).toHaveBeenCalledWith( + "https://example.com/api/", + { "X-Service-Key": "secret" }, + ); + expect(service.defaultHeaders).toEqual({ "X-Service-Key": "secret" }); }); }); diff --git a/src/service/providers/custom.ts b/src/service/providers/custom.ts index b733fb0..509d3c4 100644 --- a/src/service/providers/custom.ts +++ b/src/service/providers/custom.ts @@ -75,7 +75,11 @@ function getAuthorizationCodeOptions( | "clientId" | "oauth2Scopes" > | null { - if (options.authType !== "oauth2" && options.authType !== "login") { + if ( + options.authType !== "oauth2" || + (!options.authorizationServerUrl && + !(options.authorizationEndpoint && options.tokenEndpoint)) + ) { return null; } return { @@ -91,14 +95,48 @@ function getAuthorizationCodeOptions( function createTokenAuthHeaders( options: ServiceOptionsInput, ): Record { - const token = options.accessToken ?? options.token; - if (options.authType !== "token" || !token) { + switch (options.authType) { + case "basic": + return createBasicAuthHeaders(options); + case "token": + case "login": + case "oauth2": + return createAccessTokenHeaders(options); + case "api-key": + return createApiKeyAuthHeaders(options); + default: + return {}; + } +} + +function createBasicAuthHeaders( + options: ServiceOptionsInput, +): Record { + if (!options.username || !options.password) { + return {}; + } + return { + Authorization: `Basic ${btoa(`${options.username}:${options.password}`)}`, + }; +} + +function createAccessTokenHeaders( + options: ServiceOptionsInput, +): Record { + if (!options.accessToken) { return {}; } if (options.useBearer === true) { - return { Authorization: `Bearer ${token}` }; + return { Authorization: `Bearer ${options.accessToken}` }; + } + return { [options.accessTokenHeader ?? "X-Auth-Token"]: options.accessToken }; +} + +function createApiKeyAuthHeaders( + options: ServiceOptionsInput, +): Record { + if (!options.apiKey) { + return {}; } - const tokenHeader = - options.accessTokenHeader ?? options.tokenHeader ?? "X-Auth-Token"; - return { [tokenHeader]: token }; + return { [options.apiKeyHeader ?? "X-API-Key"]: options.apiKey }; } diff --git a/src/service/providers/url.test.ts b/src/service/providers/url.test.ts index 29f528b..bc40611 100644 --- a/src/service/providers/url.test.ts +++ b/src/service/providers/url.test.ts @@ -3,11 +3,11 @@ import { describe, expect, it } from "vitest"; import { URL_SERVICE_OPTIONS_SCHEMA } from "./url"; describe("URL service options schema", () => { - it("uses the new OAuth2 configuration while retaining hidden legacy fields", () => { + it("uses the Cuiman authentication option names", () => { expect(URL_SERVICE_OPTIONS_SCHEMA).toMatchObject({ authType: { default: "none", - enum: ["none", "token", "login", "oauth2"], + enum: ["none", "basic", "token", "login", "oauth2", "api-key"], }, accessToken: { format: "password", @@ -53,8 +53,12 @@ describe("URL service options schema", () => { "x-ui-visible": "authType === 'login' || authType === 'oauth2'", "x-ui-required": "authType === 'login' || authType === 'oauth2'", }, - token: { "x-ui-hidden": true }, - tokenHeader: { "x-ui-hidden": true }, + loginUrl: { format: "uri", "x-ui-hidden": true }, + tokenUrl: { format: "uri", "x-ui-hidden": true }, + grantType: { + enum: ["password", "client_credentials"], + "x-ui-hidden": true, + }, }); }); }); diff --git a/src/service/providers/url.ts b/src/service/providers/url.ts index 58ca53f..cc47c68 100644 --- a/src/service/providers/url.ts +++ b/src/service/providers/url.ts @@ -15,12 +15,15 @@ export interface UrlServiceOptions extends ServiceOptions { authType: AuthType; username?: string; password?: string; + loginUrl?: string; + tokenUrl?: string; + grantType?: "password" | "client_credentials"; clientId?: string; clientSecret?: string; refreshToken?: string; - token?: string; useBearer?: boolean; - tokenHeader?: string; + accessToken?: string; + accessTokenHeader?: string; apiKey?: string; apiKeyHeader?: string; authorizationServerUrl?: string; @@ -28,8 +31,6 @@ export interface UrlServiceOptions extends ServiceOptions { authorizationEndpoint?: string; tokenEndpoint?: string; oauth2Scopes?: string; - accessToken?: string; - accessTokenHeader?: string; } export type UrlServiceOptionsSchema = ServiceOptionsSchema; @@ -38,7 +39,8 @@ export const URL_SERVICE_OPTIONS_SCHEMA: UrlServiceOptionsSchema = { apiUrl: { type: "string", title: "Service API URL", - default: import.meta.env.VITE_DEFAULT_SERVICE_API_URL || "http://localhost:8008", + default: + import.meta.env.VITE_DEFAULT_SERVICE_API_URL || "http://localhost:8008", format: "uri", }, @@ -47,10 +49,11 @@ export const URL_SERVICE_OPTIONS_SCHEMA: UrlServiceOptionsSchema = { title: "Authentication Type", description: "Choose how requests to the service are authorized.", default: import.meta.env.VITE_DEFAULT_SERVICE_AUTH_TYPE || "none", - enum: ["none", "token", "login", "oauth2"], + enum: ["none", "basic", "token", "login", "oauth2", "api-key"], }, - // Reserved for a proprietary login flow, which is not implemented yet. + // Reserved for a proprietary "basic" and "login" flow, + // which are not implemented yet. username: { type: "string", title: "Username", @@ -65,7 +68,28 @@ export const URL_SERVICE_OPTIONS_SCHEMA: UrlServiceOptionsSchema = { "x-ui-hidden": true, }, - // Kept for legacy proprietary-login configurations and reused by OAuth2. + loginUrl: { + type: "string", + title: "Login URL", + nullable: true, + format: "uri", + "x-ui-hidden": true, + }, + tokenUrl: { + type: "string", + title: "OAuth2 token URL", + nullable: true, + format: "uri", + "x-ui-hidden": true, + }, + grantType: { + type: "string", + title: "OAuth2 grant type", + nullable: true, + enum: ["password", "client_credentials"], + "x-ui-hidden": true, + }, + clientId: { type: "string", title: "Client ID", @@ -82,8 +106,9 @@ export const URL_SERVICE_OPTIONS_SCHEMA: UrlServiceOptionsSchema = { format: "password", "x-ui-hidden": true, }, - // For type "login", token refresh phase — set after a successful login if the server - // returned a refresh token; presence of this field activates automatic token refresh on 401 + // For type "login" and "oauth2", token refresh phase — set after a + // successful login if the server returned a refresh token; + // Presence of this field activates automatic token refresh on 401 refreshToken: { type: "string", title: "Refresh token", @@ -92,17 +117,16 @@ export const URL_SERVICE_OPTIONS_SCHEMA: UrlServiceOptionsSchema = { "x-ui-hidden": true, }, - // For type "token" or "login" - token: { + // For type "token" or "login" or "oauth2" + accessToken: { type: "string", title: "Access token", description: "The token sent with each request to the service.", - nullable: true, format: "password", - "x-ui-hidden": true, + "x-ui-visible": "authType === 'token'", + "x-ui-required": "authType === 'token'", }, - // For type "token": custom header or Bearer useBearer: { type: "boolean", title: "Use Authorization: Bearer header", @@ -111,11 +135,11 @@ export const URL_SERVICE_OPTIONS_SCHEMA: UrlServiceOptionsSchema = { default: true, "x-ui-visible": "authType === 'token'", }, - tokenHeader: { + accessTokenHeader: { type: "string", - title: "Name of the token header", + title: "Access token header", default: "X-Auth-Token", - "x-ui-hidden": true, + "x-ui-visible": "authType === 'token' && !useBearer", }, // For type "api-key" @@ -182,18 +206,4 @@ export const URL_SERVICE_OPTIONS_SCHEMA: UrlServiceOptionsSchema = { nullable: true, "x-ui-visible": "authType === 'login' || authType === 'oauth2'", }, - accessToken: { - type: "string", - title: "Access token", - description: "The token sent with each request to the service.", - format: "password", - "x-ui-visible": "authType === 'token'", - "x-ui-required": "authType === 'token'", - }, - accessTokenHeader: { - type: "string", - title: "Access token header", - default: "X-Auth-Token", - "x-ui-visible": "authType === 'token' && !useBearer", - }, };