From febcf372eecce2cdfed6dead0933d46b8797e2e1 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 19 Apr 2024 10:27:26 -0300 Subject: challenger api --- packages/taler-util/src/http-client/bank-core.ts | 3 +- packages/taler-util/src/http-client/challenger.ts | 260 +++++++++++++++++++++ packages/taler-util/src/http-client/types.ts | 268 +++++++++++++++++----- packages/taler-util/src/http-impl.node.ts | 8 +- packages/taler-util/src/index.ts | 1 + 5 files changed, 479 insertions(+), 61 deletions(-) create mode 100644 packages/taler-util/src/http-client/challenger.ts diff --git a/packages/taler-util/src/http-client/bank-core.ts b/packages/taler-util/src/http-client/bank-core.ts index be37560cd..97c1727ff 100644 --- a/packages/taler-util/src/http-client/bank-core.ts +++ b/packages/taler-util/src/http-client/bank-core.ts @@ -24,11 +24,10 @@ import { OperationOk, TalerErrorCode, codecForChallenge, - codecForTalerErrorDetail, codecForTanTransmission, opKnownAlternativeFailure, opKnownHttpFailure, - opKnownTalerFailure, + opKnownTalerFailure } from "@gnu-taler/taler-util"; import { HttpRequestLibrary, diff --git a/packages/taler-util/src/http-client/challenger.ts b/packages/taler-util/src/http-client/challenger.ts new file mode 100644 index 000000000..fa4214aa6 --- /dev/null +++ b/packages/taler-util/src/http-client/challenger.ts @@ -0,0 +1,260 @@ +import { HttpRequestLibrary, readTalerErrorResponse } from "../http-common.js"; +import { HttpStatusCode } from "../http-status-codes.js"; +import { createPlatformHttpLib } from "../http.js"; +import { LibtoolVersion } from "../libtool-version.js"; +import { + FailCasesByMethod, + ResultByMethod, + opKnownHttpFailure, + opSuccessFromHttp, + opUnknownFailure +} from "../operation.js"; +import { + AccessToken, + codecForChallengeCreateResponse, + codecForChallengeSetupResponse, + codecForChallengeStatus, + codecForChallengerAuthResponse, + codecForChallengerInfoResponse, + codecForChallengerTermsOfServiceResponse +} from "./types.js"; +import { makeBearerTokenAuthHeader } from "./utils.js"; + +export type ChallengerResultByMethod = + ResultByMethod; +export type ChallengerErrorsByMethod = + FailCasesByMethod; + +/** + */ +export class ChallengerHttpClient { + httpLib: HttpRequestLibrary; + public readonly PROTOCOL_VERSION = "1:0:0"; + + constructor( + readonly baseUrl: string, + httpClient?: HttpRequestLibrary, + ) { + this.httpLib = httpClient ?? createPlatformHttpLib(); + } + + isCompatible(version: string): boolean { + const compare = LibtoolVersion.compare(this.PROTOCOL_VERSION, version); + return compare?.compatible ?? false; + } + /** + * https://docs.taler.net/core/api-challenger.html#get--config + * + */ + async getConfig() { + const url = new URL(`config`, this.baseUrl); + const resp = await this.httpLib.fetch(url.href, { + method: "GET", + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp( + resp, + codecForChallengerTermsOfServiceResponse(), + ); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownFailure(resp, await readTalerErrorResponse(resp)); + } + } + /** + * https://docs.taler.net/core/api-challenger.html#post--setup-$CLIENT_ID + * + */ + async setup(clientId: string, token: AccessToken) { + const url = new URL(`setup/${clientId}`, this.baseUrl); + const resp = await this.httpLib.fetch(url.href, { + method: "POST", + headers: { + Authorization: makeBearerTokenAuthHeader(token), + }, + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp(resp, codecForChallengeSetupResponse()); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownFailure(resp, await readTalerErrorResponse(resp)); + } + } + + // LOGIN + + /** + * https://docs.taler.net/core/api-challenger.html#post--authorize-$NONCE + * + */ + async login(nonce: string, clientId: string, redirectUri: string, state: string | undefined) { + const url = new URL(`authorize/${nonce}`, this.baseUrl); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", redirectUri); + if (state) { + url.searchParams.set("state", state); + } + // url.searchParams.set("scope", "code"); + const resp = await this.httpLib.fetch(url.href, { + method: "POST", + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp(resp, codecForChallengeStatus()); + case HttpStatusCode.BadRequest: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotAcceptable: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.InternalServerError: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownFailure(resp, await readTalerErrorResponse(resp)); + } + } + + // CHALLENGE + + /** + * https://docs.taler.net/core/api-challenger.html#post--challenge-$NONCE + * + */ + async challenge(nonce: string, body: Record<"email", string>) { + const url = new URL(`challenge/${nonce}`, this.baseUrl); + + const resp = await this.httpLib.fetch(url.href, { + method: "POST", + body: new URLSearchParams(Object.entries(body)).toString(), + headers: { + "Content-Type": "application/x-www-form-urlencoded" + } + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp(resp, codecForChallengeCreateResponse()); + case HttpStatusCode.BadRequest: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotAcceptable: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.TooManyRequests: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.InternalServerError: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownFailure(resp, await readTalerErrorResponse(resp)); + } + } + + // SOLVE + + /** + * https://docs.taler.net/core/api-challenger.html#post--solve-$NONCE + * + */ + async solve(nonce: string, body: Record) { + const url = new URL(`solve/${nonce}`, this.baseUrl); + const resp = await this.httpLib.fetch(url.href, { + method: "POST", + body: new URLSearchParams(Object.entries(body)).toString(), + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + redirect: "manual", + }); + switch (resp.status) { + case HttpStatusCode.Found: + const redirect = resp.headers.get("Location")! + const uri = new URL(redirect) + const code = uri.searchParams.get("code")! + return { + type: "ok" as const, + body: { code } + } + // return opSuccessFromHttp(resp, codecForChallengeCreateResponse()); + case HttpStatusCode.BadRequest: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotAcceptable: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.TooManyRequests: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.InternalServerError: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownFailure(resp, await readTalerErrorResponse(resp)); + } + } + + // AUTH + + /** + * https://docs.taler.net/core/api-challenger.html#post--token + * + */ + async token( + client_id: string, + redirect_uri: string, + client_secret: AccessToken, + code: string, + ) { + const url = new URL(`token`, this.baseUrl); + const resp = await this.httpLib.fetch(url.href, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: new URLSearchParams(Object.entries({ + client_id, + redirect_uri, + client_secret, + code, + grant_type: "authorization_code", + })).toString(), + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp(resp, codecForChallengerAuthResponse()); + case HttpStatusCode.Forbidden: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownFailure(resp, await readTalerErrorResponse(resp)); + } + } + + // INFO + + /** + * https://docs.taler.net/core/api-challenger.html#get--info + * + */ + async info(token: AccessToken) { + const url = new URL(`info`, this.baseUrl); + const resp = await this.httpLib.fetch(url.href, { + method: "GET", + headers: { + Authorization: makeBearerTokenAuthHeader(token), + }, + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp(resp, codecForChallengerInfoResponse()); + case HttpStatusCode.Forbidden: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownFailure(resp, await readTalerErrorResponse(resp)); + } + } +} diff --git a/packages/taler-util/src/http-client/types.ts b/packages/taler-util/src/http-client/types.ts index 94eafb329..e12c2ed6b 100644 --- a/packages/taler-util/src/http-client/types.ts +++ b/packages/taler-util/src/http-client/types.ts @@ -195,12 +195,36 @@ export type AccessToken = string & { /** * Create a rfc8959 access token. * Adds secret-token: prefix if there is none. - * - * @param token - * @returns + * + * @deprecated use createRFC8959AccessToken + * @param token + * @returns */ export function createAccessToken(token: string): AccessToken { - return (token.startsWith("secret-token:") ? token : `secret-token:${token}`) as AccessToken + return ( + token.startsWith("secret-token:") ? token : `secret-token:${token}` + ) as AccessToken; +} +/** + * Create a rfc8959 access token. + * Adds secret-token: prefix if there is none. + * + * @param token + * @returns + */ +export function createRFC8959AccessToken(token: string): AccessToken { + return ( + token.startsWith("secret-token:") ? token : `secret-token:${token}` + ) as AccessToken; +} +/** + * Conver string to access token. + * + * @param clientSecret + * @returns + */ +export function createClientSecretAccessToken(clientSecret: string): AccessToken { + return clientSecret as AccessToken; } declare const __officer_signature: unique symbol; @@ -1430,51 +1454,6 @@ export const codecForAmlDecision = (): Codec => .property("kyc_requirements", codecOptional(codecForList(codecForString()))) .build("TalerExchangeApi.AmlDecision"); -// version: string; - -// // Name of the API. -// name: "taler-conversion-info"; - -// // Currency used by this bank. -// regional_currency: string; - -// // How the bank SPA should render this currency. -// regional_currency_specification: CurrencySpecification; - -// // External currency used during conversion. -// fiat_currency: string; - -// // How the bank SPA should render this currency. -// fiat_currency_specification: CurrencySpecification; - -// Extra conversion rate information. -// // Only present if server opts in to report the static conversion rate. -// conversion_info?: { - -// // Fee to subtract after applying the cashin ratio. -// cashin_fee: AmountString; - -// // Fee to subtract after applying the cashout ratio. -// cashout_fee: AmountString; - -// // Minimum amount authorised for cashin, in fiat before conversion -// cashin_min_amount: AmountString; - -// // Minimum amount authorised for cashout, in regional before conversion -// cashout_min_amount: AmountString; - -// // Smallest possible regional amount, converted amount is rounded to this amount -// cashin_tiny_amount: AmountString; - -// // Smallest possible fiat amount, converted amount is rounded to this amount -// cashout_tiny_amount: AmountString; - -// // Rounding mode used during cashin conversion -// cashin_rounding_mode: "zero" | "up" | "nearest"; - -// // Rounding mode used during cashout conversion -// cashout_rounding_mode: "zero" | "up" | "nearest"; -// } export const codecForConversionInfo = (): Codec => buildCodecForObject() @@ -1520,11 +1499,65 @@ export const codecForConversionBankConfig = .property("conversion_rate", codecForConversionInfo()) .build("ConversionBankConfig.IntegrationConfig"); -// export const codecFor = -// (): Codec => -// buildCodecForObject() -// .property("", codecForString()) -// .build("TalerWireGatewayApi.PublicAccountsResponse"); +export const codecForChallengerTermsOfServiceResponse = + (): Codec => + buildCodecForObject() + .property("name", codecForConstString("challenger")) + .property("version", codecForString()) + .property("implementation", codecOptional(codecForString())) + .build("ChallengerApi.ChallengerTermsOfServiceResponse"); + +export const codecForChallengeSetupResponse = + (): Codec => + buildCodecForObject() + .property("nonce", codecForString()) + .build("ChallengerApi.ChallengeSetupResponse"); + +export const codecForChallengeStatus = + (): Codec => + buildCodecForObject() + .property("restrictions", codecForAny()) + .property("fix_address", codecForBoolean()) + .property("last_address", codecForAny()) + .property("changes_left", codecForNumber()) + .build("ChallengerApi.ChallengeStatus"); +export const codecForChallengeCreateResponse = + (): Codec => + buildCodecForObject() + .property("attempts_left", codecForNumber()) + .property("address", codecForAny()) + .property("transmitted", codecForBoolean()) + .property("next_tx_time", codecForString()) + .build("ChallengerApi.ChallengeCreateResponse"); + +export const codecForInvalidPinResponse = + (): Codec => + buildCodecForObject() + .property("ec", codecForNumber()) + .property("hint", codecForAny()) + .property("addresses_left", codecForNumber()) + .property("pin_transmissions_left", codecForNumber()) + .property("auth_attempts_left", codecForNumber()) + .property("exhausted", codecForBoolean()) + .property("no_challenge", codecForBoolean()) + .build("ChallengerApi.InvalidPinResponse"); + +export const codecForChallengerAuthResponse = + (): Codec => + buildCodecForObject() + .property("access_token", codecForString()) + .property("token_type", codecForAny()) + .property("expires_in", codecForNumber()) + .build("ChallengerApi.ChallengerAuthResponse"); + +export const codecForChallengerInfoResponse = + (): Codec => + buildCodecForObject() + .property("id", codecForNumber()) + .property("address", codecForAny()) + .property("address_type", codecForString()) + .property("expires", codecForTimestamp) + .build("ChallengerApi.ChallengerInfoResponse"); type EmailAddress = string; type PhoneNumber = string; @@ -1896,6 +1929,7 @@ export namespace TalerBankConversionApi { cashout_rounding_mode: RoundingMode; } } + export namespace TalerBankIntegrationApi { export interface BankVersion { // libtool-style representation of the Bank protocol version, see @@ -1973,6 +2007,7 @@ export namespace TalerBankIntegrationApi { confirm_transfer_url?: string; } } + export namespace TalerCorebankApi { export interface IntegrationConfig { // libtool-style representation of the Bank protocol version, see @@ -2103,7 +2138,7 @@ export namespace TalerCorebankApi { // query string parameter of the 'payto' field. In case it // is given in both places, the paytoUri's takes the precedence. amount?: AmountString; - + // Nonce to make the request idempotent. Requests with the same // request_uid that differ in any of the other fields // are rejected. @@ -5223,3 +5258,126 @@ export namespace TalerMerchantApi { master_pub: EddsaPublicKey; } } + +export namespace ChallengerApi { + export interface ChallengerTermsOfServiceResponse { + // Name of the service + name: "challenger"; + + // libtool-style representation of the Challenger protocol version, see + // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning + // The format is "current:revision:age". + version: string; + + // URN of the implementation (needed to interpret 'revision' in version). + // @since v0, may become mandatory in the future. + implementation?: string; + } + + export interface ChallengeSetupResponse { + // Nonce to use when constructing /authorize endpoint. + nonce: string; + } + + export interface ChallengeStatus { + // Object; map of keys (names of the fields of the address + // to be entered by the user) to objects with a "regex" (string) + // containing an extended Posix regular expression for allowed + // address field values, and a "hint"/"hint_i18n" giving a + // human-readable explanation to display if the value entered + // by the user does not match the regex. Keys that are not mapped + // to such an object have no restriction on the value provided by + // the user. See "ADDRESS_RESTRICTIONS" in the challenger configuration. + restrictions: Object; + + // indicates if the given address cannot be changed anymore, the + // form should be read-only if set to true. + fix_address: boolean; + + // form values from the previous submission if available, details depend + // on the ADDRESS_TYPE, should be used to pre-populate the form + last_address: Object; + + // number of times the address can still be changed, may or may not be + // shown to the user + changes_left: Integer; + } + + export interface ChallengeCreateResponse { + // how many more attempts are allowed, might be shown to the user, + // highlighting might be appropriate for low values such as 1 or 2 (the + // form will never be used if the value is zero) + attempts_left: Integer; + + // the address that is being validated, might be shown or not + address: Object; + + // true if we just retransmitted the challenge, false if we sent a + // challenge recently and thus refused to transmit it again this time; + // might make a useful hint to the user + transmitted: boolean; + + // timestamp explaining when we would re-transmit the challenge the next + // time (at the earliest) if requested by the user + next_tx_time: String; + } + + export interface InvalidPinResponse { + // numeric Taler error code, should be shown to indicate the error + // compactly for reporting to developers + ec: Integer; + + // human-readable Taler error code, should be shown for the user to + // understand the error + hint: String; + + // how many times is the user still allowed to change the address; + // if 0, the user should not be shown a link to jump to the + // address entry form + addresses_left: Integer; + + // how many times might the PIN still be retransmitted + pin_transmissions_left: Integer; + + // how many times might the user still try entering the PIN code + auth_attempts_left: Integer; + + // if true, the PIN was not even evaluated as the user previously + // exhausted the number of attempts + exhausted: boolean; + + // if true, the PIN was not even evaluated as no challenge was ever + // issued (the user must have skipped the step of providing their + // address first!) + no_challenge: boolean; + } + + export interface ChallengerAuthResponse { + // Token used to authenticate access in /info. + access_token: string; + + // Type of the access token. + token_type: "Bearer"; + + // Amount of time that an access token is valid (in seconds). + expires_in: Integer; + } + + export interface ChallengerInfoResponse { + // Unique ID of the record within Challenger + // (identifies the rowid of the token). + id: Integer; + + // Address that was validated. + // Key-value pairs, details depend on the + // address_type. + address: Object; + + // Type of the address. + address_type: string; + + // How long do we consider the address to be + // valid for this user. + expires: Timestamp; + } +} diff --git a/packages/taler-util/src/http-impl.node.ts b/packages/taler-util/src/http-impl.node.ts index 8606bc451..45a12c258 100644 --- a/packages/taler-util/src/http-impl.node.ts +++ b/packages/taler-util/src/http-impl.node.ts @@ -123,8 +123,8 @@ export class HttpLibImpl implements HttpRequestLibrary { if (opt?.headers) { Object.entries(opt?.headers).forEach(([key, value]) => { if (value === undefined) return; - requestHeadersMap[key] = value - }) + requestHeadersMap[key] = value; + }); } logger.trace(`request timeout ${timeoutMs} ms`); @@ -181,10 +181,10 @@ export class HttpLibImpl implements HttpRequestLibrary { return arg + " '" + String(v) + "'"; } console.log( - `curl -X ${options.method} ${parsedUrl.href} ${ifUndefined( + `curl -X ${options.method} "${parsedUrl.href}" ${headers} ${ifUndefined( "-d", payload, - )} ${headers}`, + )}`, ); } diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts index 9bd4834d2..24d6e9950 100644 --- a/packages/taler-util/src/index.ts +++ b/packages/taler-util/src/index.ts @@ -22,6 +22,7 @@ export * from "./http-client/bank-conversion.js"; export * from "./http-client/authentication.js"; export * from "./http-client/bank-core.js"; export * from "./http-client/merchant.js"; +export * from "./http-client/challenger.js"; export * from "./http-client/bank-integration.js"; export * from "./http-client/bank-revenue.js"; export * from "./http-client/bank-wire.js"; -- cgit v1.2.3