diff --git a/@stellar/typescript-wallet-sdk/package.json b/@stellar/typescript-wallet-sdk/package.json index 80886f3..db16e7d 100644 --- a/@stellar/typescript-wallet-sdk/package.json +++ b/@stellar/typescript-wallet-sdk/package.json @@ -45,11 +45,14 @@ "dependencies": { "@stellar/stellar-sdk": "^11.1.0", "axios": "^1.4.0", + "base64url": "^3.0.1", "https-browserify": "^1.0.0", "jws": "^4.0.0", "lodash": "^4.17.21", "query-string": "^7.1.3", "stream-http": "^3.2.0", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1", "url": "^0.11.0", "util": "^0.12.5", "utility-types": "^3.10.0", diff --git a/@stellar/typescript-wallet-sdk/src/index.ts b/@stellar/typescript-wallet-sdk/src/index.ts index 74d009e..cb6050b 100644 --- a/@stellar/typescript-wallet-sdk/src/index.ts +++ b/@stellar/typescript-wallet-sdk/src/index.ts @@ -17,6 +17,11 @@ export { Anchor } from "./walletSdk/Anchor"; export { Sep24 } from "./walletSdk/Anchor/Sep24"; export { IssuedAssetId, NativeAssetId, FiatAssetId } from "./walletSdk/Asset"; export { Sep10, WalletSigner, DefaultSigner } from "./walletSdk/Auth"; +export { + AuthHeaderSigner, + DefaultAuthHeaderSigner, + DomainAuthHeaderSigner, +} from "./walletSdk/Auth/AuthHeaderSigner"; export { AccountKeypair, PublicKeypair, diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Auth/AuthHeaderSigner.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Auth/AuthHeaderSigner.ts new file mode 100644 index 0000000..4a96322 --- /dev/null +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Auth/AuthHeaderSigner.ts @@ -0,0 +1,158 @@ +import { AxiosInstance } from "axios"; +import { StrKey } from "@stellar/stellar-sdk"; +import nacl from "tweetnacl"; +import naclUtil from "tweetnacl-util"; +import base64url from "base64url"; + +import { SigningKeypair } from "../Horizon/Account"; +import { DefaultClient } from "../"; +import { AuthHeaderClaims, AuthHeaderCreateTokenParams } from "../Types"; +import { + AuthHeaderSigningKeypairRequiredError, + AuthHeaderClientDomainRequiredError, +} from "../Exceptions"; + +export interface AuthHeaderSigner { + createToken({ + claims, + clientDomain, + issuer, + }: AuthHeaderCreateTokenParams): Promise; +} + +/** + * Signer for signing JWT for GET /Auth with a custodial private key + * + * @class + */ +export class DefaultAuthHeaderSigner implements AuthHeaderSigner { + expiration: number; + + constructor(expiration: number = 900) { + this.expiration = expiration; + } + + /** + * Create a signed JWT for the auth header + * @constructor + * @param {AuthHeaderCreateTokenParams} params - The create token parameters + * @param {AuthHeaderClaims} params.claims - the data to be signed in the JWT + * @param {string} [params.clientDomain] - the client domain hosting SEP-1 toml + * @param {AccountKeypair} [params.issuer] - the account signing the JWT + * @returns {Promise} The signed JWT + */ + // eslint-disable-next-line @typescript-eslint/require-await + async createToken({ + claims, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + clientDomain, + issuer, + }: AuthHeaderCreateTokenParams): Promise { + if (!(issuer instanceof SigningKeypair)) { + throw new AuthHeaderSigningKeypairRequiredError(); + } + + const issuedAt = Math.floor(Date.now() / 1000); + const timeExp = Math.floor(Date.now() / 1000) + this.expiration; + + const rawSeed = StrKey.decodeEd25519SecretSeed(issuer.secretKey); + const naclKP = nacl.sign.keyPair.fromSeed(rawSeed); + + const header = { alg: "EdDSA", typ: "JWT" }; + const encodedHeader = base64url(JSON.stringify(header)); + const encodedPayload = base64url( + JSON.stringify({ ...claims, exp: timeExp, iat: issuedAt }), + ); + + const signature = nacl.sign.detached( + naclUtil.decodeUTF8(`${encodedHeader}.${encodedPayload}`), + naclKP.secretKey, + ); + const encodedSignature = base64url(Buffer.from(signature)); + + const jwt = `${encodedHeader}.${encodedPayload}.${encodedSignature}`; + return jwt; + } +} + +/** + * Signer for signing JWT for GET /Auth using a remote server to sign. + * + * @class + */ +export class DomainAuthHeaderSigner implements AuthHeaderSigner { + signerUrl: string; + expiration: number; + httpClient: AxiosInstance; + + constructor( + signerUrl: string, + expiration: number = 900, + httpClient?: AxiosInstance, + ) { + this.signerUrl = signerUrl; + this.expiration = expiration; + this.httpClient = httpClient || DefaultClient; + } + + /** + * Create a signed JWT for the auth header by using a remote server to sign the JWT + * @constructor + * @param {AuthHeaderCreateTokenParams} params - The create token parameters + * @param {AuthHeaderClaims} params.claims - the data to be signed in the JWT + * @param {string} [params.clientDomain] - the client domain hosting SEP-1 toml + * @param {AccountKeypair} [params.issuer] - unused, will not be used to sign + * @returns {Promise} The signed JWT + */ + async createToken({ + claims, + clientDomain, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + issuer, + }: AuthHeaderCreateTokenParams): Promise { + if (!clientDomain) { + throw new AuthHeaderClientDomainRequiredError(); + } + + const issuedAt = Math.floor(Date.now() / 1000); + const expiration = Math.floor(Date.now() / 1000) + this.expiration; + + return await this.signTokenRemote({ + claims, + clientDomain, + expiration, + issuedAt, + }); + } + + /** + * Sign JWT by calling a remote server + * @constructor + * @param {SignTokenRemoteParams} params - the sign token params + * @param {AuthHeaderClaims} params.claims - the data to be signed in the JWT + * @param {string} params.clientDomain - the client domain hosting SEP-1 toml + * @param {number} params.expiration - when the token should expire + * @param {number} params.issuedAt - when the token was created + * @returns {Promise} The signed JWT + */ + async signTokenRemote({ + claims, + clientDomain, + expiration, + issuedAt, + }: { + claims: AuthHeaderClaims; + clientDomain: string; + expiration: number; + issuedAt: number; + }): Promise { + const resp = await this.httpClient.post(this.signerUrl, { + clientDomain, + expiration, + issuedAt, + ...claims, + }); + + return resp.data.token; + } +} diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Auth/index.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Auth/index.ts index c7d722c..e7bc952 100644 --- a/@stellar/typescript-wallet-sdk/src/walletSdk/Auth/index.ts +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Auth/index.ts @@ -17,7 +17,10 @@ import { ChallengeParams, ChallengeResponse, SignParams, + AuthHeaderClaims, } from "../Types"; +import { AccountKeypair } from "../Horizon/Account"; +import { AuthHeaderSigner } from "./AuthHeaderSigner"; export { WalletSigner, DefaultSigner } from "./WalletSigner"; @@ -71,11 +74,13 @@ export class Sep10 { walletSigner, memoId, clientDomain, + authHeaderSigner, }: AuthenticateParams): Promise { const challengeResponse = await this.challenge({ accountKp, memoId, clientDomain: clientDomain || this.cfg.app.defaultClientDomain, + authHeaderSigner, }); const signedTransaction = await this.sign({ accountKp, @@ -90,6 +95,7 @@ export class Sep10 { accountKp, memoId, clientDomain, + authHeaderSigner, }: ChallengeParams): Promise { if (memoId && parseInt(memoId) < 0) { throw new InvalidMemoError(); @@ -104,8 +110,29 @@ export class Sep10 { }${clientDomain ? `&client_domain=${clientDomain}` : ""}${ this.homeDomain ? `&home_domain=${this.homeDomain}` : "" }`; + + const claims = { + account: accountKp.publicKey, + home_domain: this.homeDomain, + memo: memoId, + client_domain: clientDomain, + webAuthEndpoint: this.webAuthEndpoint, + }; + + const token = await createAuthSignToken( + accountKp, + claims, + clientDomain, + authHeaderSigner, + ); + + let headers = {}; + if (token) { + headers = { Authorization: `Bearer ${token}` }; + } + try { - const resp = await this.httpClient.get(url); + const resp = await this.httpClient.get(url, { headers }); const challengeResponse: ChallengeResponse = resp.data; return challengeResponse; } catch (e) { @@ -165,3 +192,22 @@ const validateToken = (token: string) => { throw new ExpiredTokenError(parsedToken.expiresAt); } }; + +const createAuthSignToken = async ( + account: AccountKeypair, + claims: AuthHeaderClaims, + clientDomain?: string, + authHeaderSigner?: AuthHeaderSigner, +) => { + if (!authHeaderSigner) { + return null; + } + + const issuer = clientDomain ? null : account; + + return authHeaderSigner.createToken({ + claims, + clientDomain, + issuer, + }); +}; diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Exceptions/index.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Exceptions/index.ts index 126e7d3..eb8aceb 100644 --- a/@stellar/typescript-wallet-sdk/src/walletSdk/Exceptions/index.ts +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Exceptions/index.ts @@ -305,3 +305,22 @@ export class InvalidJsonError extends Error { Object.setPrototypeOf(this, InvalidJsonError.prototype); } } + +export class AuthHeaderSigningKeypairRequiredError extends Error { + constructor() { + super("Must be SigningKeypair to sign auth header"); + Object.setPrototypeOf( + this, + AuthHeaderSigningKeypairRequiredError.prototype, + ); + } +} + +export class AuthHeaderClientDomainRequiredError extends Error { + constructor() { + super( + "This class should only be used for remote signing. For local signing use DefaultAuthHeaderSigner.", + ); + Object.setPrototypeOf(this, AuthHeaderClientDomainRequiredError.prototype); + } +} diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Types/auth.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Types/auth.ts index ee494da..2c260fe 100644 --- a/@stellar/typescript-wallet-sdk/src/walletSdk/Types/auth.ts +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Types/auth.ts @@ -3,12 +3,14 @@ import { decode } from "jws"; import { WalletSigner } from "../Auth/WalletSigner"; import { AccountKeypair, SigningKeypair } from "../Horizon/Account"; +import { AuthHeaderSigner } from "../Auth/AuthHeaderSigner"; export type AuthenticateParams = { accountKp: AccountKeypair; walletSigner?: WalletSigner; memoId?: string; clientDomain?: string; + authHeaderSigner?: AuthHeaderSigner; }; export class AuthToken { @@ -49,6 +51,7 @@ export type ChallengeParams = { accountKp: AccountKeypair; memoId?: string; clientDomain?: string; + authHeaderSigner?: AuthHeaderSigner; }; export type XdrEncodedTransaction = string; @@ -91,3 +94,17 @@ export type SignChallengeTxnResponse = { transaction: XdrEncodedTransaction; networkPassphrase: NetworkPassphrase; }; + +export type AuthHeaderClaims = { + account: string; + home_domain: string; + webAuthEndpoint: string; + memo?: string; + client_domain?: string; +}; + +export type AuthHeaderCreateTokenParams = { + claims: AuthHeaderClaims; + clientDomain?: string; + issuer?: AccountKeypair; +}; diff --git a/@stellar/typescript-wallet-sdk/test/wallet.test.ts b/@stellar/typescript-wallet-sdk/test/wallet.test.ts index c2f3ea7..78a431b 100644 --- a/@stellar/typescript-wallet-sdk/test/wallet.test.ts +++ b/@stellar/typescript-wallet-sdk/test/wallet.test.ts @@ -20,6 +20,10 @@ import { WalletSigner, DefaultSigner, } from "../src/walletSdk/Auth/WalletSigner"; +import { + DefaultAuthHeaderSigner, + DomainAuthHeaderSigner, +} from "../src/walletSdk/Auth/AuthHeaderSigner"; import { SigningKeypair } from "../src/walletSdk/Horizon/Account"; import { Sep24 } from "../src/walletSdk/Anchor/Sep24"; import { DomainSigner } from "../src/walletSdk/Auth/WalletSigner"; @@ -1880,3 +1884,56 @@ describe("Http client", () => { expect(resp.data.transaction).toBeTruthy(); }); }); + +describe("AuthHeaderSigner", () => { + it("full sep-10 auth using header token should work", async () => { + const wallet = Wallet.TestNet(); + const accountKp = wallet.stellar().account().createKeypair(); + wallet.stellar().fundTestnetAccount(accountKp.publicKey); + + const anchor = wallet.anchor({ homeDomain: "testanchor.stellar.org" }); + const auth = await anchor.sep10(); + + const authHeaderSigner = new DefaultAuthHeaderSigner(); + const authToken = await auth.authenticate({ + accountKp, + authHeaderSigner, + }); + + expect(authToken).toBeTruthy(); + }, 15000); + + it("DefaultAuthHeaderSigner should work", async () => { + const accountKp = SigningKeypair.fromSecret( + "SAFXVNFRZQAC66RUZ2IJKMSNQCPXTKXVRX356COUKJJKJXBSLRX43DEZ", + ); + + const signer = new DefaultAuthHeaderSigner(); + const token = await signer.createToken({ + claims: {}, + clientDomain: "test-domain", + issuer: accountKp, + }); + expect(token).toBeTruthy(); + }); + + it("DomainAuthHeaderSigner should work", async () => { + const accountKp = SigningKeypair.fromSecret( + "SAFXVNFRZQAC66RUZ2IJKMSNQCPXTKXVRX356COUKJJKJXBSLRX43DEZ", + ); + + const signer = new DomainAuthHeaderSigner("some-url.com"); + + const data = { account: "dummy-account" }; + + jest.spyOn(signer, "signTokenRemote").mockResolvedValue("success-token"); + + const token = await signer.createToken({ + authTokenData: data, + clientDomain: "test-domain", + issuer: accountKp, + }); + + expect(token).toBe("success-token"); + }); +}); diff --git a/@stellar/typescript-wallet-sdk/webpack.config.js b/@stellar/typescript-wallet-sdk/webpack.config.js index c624301..1067ba9 100644 --- a/@stellar/typescript-wallet-sdk/webpack.config.js +++ b/@stellar/typescript-wallet-sdk/webpack.config.js @@ -29,6 +29,7 @@ module.exports = (env = { NODE: false }) => { util: require.resolve("util"), vm: require.resolve("vm-browserify"), "process/browser": require.resolve("process/browser"), + buffer: require.resolve("buffer"), } : {}, }, @@ -45,6 +46,9 @@ module.exports = (env = { NODE: false }) => { new webpack.ProvidePlugin({ process: "process/browser", }), + new webpack.ProvidePlugin({ + Buffer: ["buffer", "Buffer"], + }), ] : [], }; diff --git a/yarn.lock b/yarn.lock index 2445bad..65a5f19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3411,6 +3411,11 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +base64url@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d" + integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A== + bignumber.js@^9.1.2: version "9.1.2" resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.2.tgz#b7c4242259c008903b13707983b5f4bbd31eda0c"