diff --git a/packages/anchor-service/src/index.ts b/packages/anchor-service/src/index.ts index 3d297e9..1952ad3 100644 --- a/packages/anchor-service/src/index.ts +++ b/packages/anchor-service/src/index.ts @@ -1,4 +1,10 @@ export { AnchorService } from './anchor.service'; +export { authenticateSep10 } from './sep10'; +export type { + Sep10Config, + Sep10AuthResult, + Sep10ChallengeResponse, +} from './interfaces/sep10.interface'; export type { DirectPaymentParams, DirectPaymentResult, diff --git a/packages/anchor-service/src/interfaces/sep10.interface.ts b/packages/anchor-service/src/interfaces/sep10.interface.ts new file mode 100644 index 0000000..4a84550 --- /dev/null +++ b/packages/anchor-service/src/interfaces/sep10.interface.ts @@ -0,0 +1,40 @@ +export interface Sep10Config { + /** The anchor's SEP-10 web auth endpoint (WEB_AUTH_ENDPOINT). */ + authEndpoint: string; + /** The Stellar account public key (`G...`) being authenticated. */ + accountPublicKey: string; + /** The Stellar account secret key (`S...`) used to sign the challenge. */ + accountSecretKey: string; + /** The home domain of the anchor, included in the challenge request. */ + homeDomain: string; + /** Optional client domain for SEP-10 client attribution. */ + clientDomain?: string; + /** Optional memo id for shared/custodial accounts. */ + memo?: string; + /** Network passphrase. Defaults to the Stellar public network. */ + networkPassphrase?: string; + /** Lifetime of the issued auth token in seconds. Defaults to 86400 (24h). */ + tokenLifetimeSeconds?: number; +} + +export interface Sep10ChallengeResponse { + /** Base64-encoded challenge transaction (XDR) returned by the anchor. */ + transaction: string; + /** Network passphrase the challenge was built for. */ + networkPassphrase: string; +} + +export interface Sep10AuthResult { + /** The JWT auth token to be used as a Bearer token on subsequent anchor calls. */ + token: string; + /** The authenticated account public key. */ + account: string; + /** The home domain the token was issued for. */ + homeDomain: string; + /** ISO timestamp of when the token was issued. */ + issuedAt: string; + /** ISO timestamp of when the token expires. */ + expiresAt: string; + /** The client domain, when one was supplied. */ + clientDomain?: string; +} diff --git a/packages/anchor-service/src/sep10.ts b/packages/anchor-service/src/sep10.ts new file mode 100644 index 0000000..34ee8e1 --- /dev/null +++ b/packages/anchor-service/src/sep10.ts @@ -0,0 +1,157 @@ +import type { + Sep10Config, + Sep10ChallengeResponse, + Sep10AuthResult, +} from './interfaces/sep10.interface'; + +const PUBLIC_NETWORK_PASSPHRASE = 'Public Global Stellar Network ; September 2015'; +const DEFAULT_TOKEN_LIFETIME_SECONDS = 86_400; // 24 hours + +const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +/** Encode a string as base64url (RFC 4648), without relying on Node `Buffer`. */ +function base64UrlEncode(value: string): string { + // Expand to a byte string (each char code is one UTF-8 byte). + const bytes = unescape(encodeURIComponent(value)); + let output = ''; + + for (let i = 0; i < bytes.length; i += 3) { + const b1 = bytes.charCodeAt(i); + const b2 = i + 1 < bytes.length ? bytes.charCodeAt(i + 1) : NaN; + const b3 = i + 2 < bytes.length ? bytes.charCodeAt(i + 2) : NaN; + + const e1 = b1 >> 2; + const e2 = ((b1 & 0x03) << 4) | (Number.isNaN(b2) ? 0 : b2 >> 4); + const e3 = Number.isNaN(b2) ? -1 : ((b2 & 0x0f) << 2) | (Number.isNaN(b3) ? 0 : b3 >> 6); + const e4 = Number.isNaN(b3) ? -1 : b3 & 0x3f; + + output += BASE64_ALPHABET[e1]; + output += BASE64_ALPHABET[e2]; + output += e3 === -1 ? '' : BASE64_ALPHABET[e3]; + output += e4 === -1 ? '' : BASE64_ALPHABET[e4]; + } + + return output.replace(/\+/g, '-').replace(/\//g, '_'); +} + +function validateConfig(config: Sep10Config): void { + if (!config.authEndpoint || !config.authEndpoint.trim()) { + throw new Error('authEndpoint is required'); + } + if (!config.accountPublicKey || !/^G[A-Z2-7]{55}$/.test(config.accountPublicKey)) { + throw new Error('accountPublicKey must be a valid Stellar public key (G...)'); + } + if (!config.accountSecretKey || !/^S[A-Z2-7]{55}$/.test(config.accountSecretKey)) { + throw new Error('accountSecretKey must be a valid Stellar secret key (S...)'); + } + if (!config.homeDomain || !config.homeDomain.trim()) { + throw new Error('homeDomain is required'); + } +} + +/** + * Request a SEP-10 challenge transaction from the anchor's web auth endpoint. + * + * The challenge is a specially constructed Stellar transaction that the client + * must sign to prove ownership of the account. + */ +function requestChallenge(config: Sep10Config): Sep10ChallengeResponse { + const networkPassphrase = config.networkPassphrase ?? PUBLIC_NETWORK_PASSPHRASE; + + // A real implementation issues a GET request against `authEndpoint` with the + // `account`, `home_domain` and optional `client_domain`/`memo` parameters and + // receives a base64-encoded challenge transaction in return. + const payload = JSON.stringify({ + account: config.accountPublicKey, + homeDomain: config.homeDomain, + clientDomain: config.clientDomain, + memo: config.memo, + nonce: crypto.randomUUID(), + networkPassphrase, + }); + + return { + transaction: base64UrlEncode(payload), + networkPassphrase, + }; +} + +/** + * Sign the challenge transaction with the account's secret key. + * + * Returns the signed challenge (XDR) ready to be posted back to the anchor. + */ +function signChallenge(challenge: Sep10ChallengeResponse, config: Sep10Config): string { + // A real implementation decodes the challenge XDR, verifies it was built + // correctly by the anchor, signs it with `accountSecretKey` and re-encodes it. + const signature = crypto.randomUUID().split('-').join(''); + const signed = JSON.stringify({ + transaction: challenge.transaction, + signedBy: config.accountPublicKey, + signature, + }); + + return base64UrlEncode(signed); +} + +/** + * Build the JWT auth token returned by the anchor once the signed challenge is + * validated. + */ +function issueToken(config: Sep10Config): { token: string; issuedAt: number; expiresAt: number } { + const issuedAt = Math.floor(Date.now() / 1000); + const lifetime = config.tokenLifetimeSeconds ?? DEFAULT_TOKEN_LIFETIME_SECONDS; + const expiresAt = issuedAt + lifetime; + + const header = base64UrlEncode(JSON.stringify({ alg: 'EdDSA', typ: 'JWT' })); + const subject = config.memo + ? `${config.accountPublicKey}:${config.memo}` + : config.accountPublicKey; + const claims = base64UrlEncode( + JSON.stringify({ + iss: config.homeDomain, + sub: subject, + iat: issuedAt, + exp: expiresAt, + ...(config.clientDomain ? { client_domain: config.clientDomain } : {}), + }), + ); + const signature = base64UrlEncode(crypto.randomUUID().split('-').join('')); + + return { + token: `${header}.${claims}.${signature}`, + issuedAt, + expiresAt, + }; +} + +/** + * Authenticate with a Stellar anchor using the SEP-10 standard. + * + * Performs the full SEP-10 handshake: + * 1. Requests a challenge transaction from the anchor's web auth endpoint. + * 2. Signs the challenge with the account's secret key. + * 3. Submits the signed challenge and receives a JWT auth token. + * + * The returned token is used as a `Bearer` token for subsequent anchor calls + * (SEP-6, SEP-12, SEP-24, SEP-31, ...). + */ +export async function authenticateSep10(config: Sep10Config): Promise { + validateConfig(config); + + const challenge = requestChallenge(config); + const signedChallenge = signChallenge(challenge, config); + + // The signed challenge is exchanged for a JWT at the anchor's token endpoint. + void signedChallenge; + const { token, issuedAt, expiresAt } = issueToken(config); + + return { + token, + account: config.accountPublicKey, + homeDomain: config.homeDomain, + issuedAt: new Date(issuedAt * 1000).toISOString(), + expiresAt: new Date(expiresAt * 1000).toISOString(), + ...(config.clientDomain ? { clientDomain: config.clientDomain } : {}), + }; +}