Skip to content

Commit

Permalink
JS SDK first try
Browse files Browse the repository at this point in the history
  • Loading branch information
bumi committed Oct 15, 2022
0 parents commit 470ab58
Show file tree
Hide file tree
Showing 15 changed files with 1,264 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist
node_modules
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
18.1.0
84 changes: 84 additions & 0 deletions examples/oauth2-public-callback_pkce_s256.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { auth, Client } from "../dist/index.js";
import express from "express";

const app = express();

const authClient = new auth.OAuth2User({
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
callback: "http://localhost:8080/callback",
scopes: ["invoices:read", "account:read", "balance:read", "invoices:create", "invoices:read", "payments:send"],
token: {access_token: undefined, refresh_token: undefined, expires_at: undefined} // initialize with existing token
});

const client = new Client(authClient);

const STATE = "my-state";

app.get("/callback", async function (req, res) {
try {
const { code, state } = req.query;
if (state !== STATE) return res.status(500).send("State isn't matching");
await authClient.requestAccessToken(code);
console.log(authClient);
const invoices = await client.accountBalance();
res.send(invoices);
} catch (error) {
console.log(error);
}
});

app.get("/login", async function (req, res) {
const authUrl = authClient.generateAuthURL({
state: STATE,
code_challenge_method: "S256",
});
res.redirect(authUrl);
});

app.get("/balance", async function (req, res) {
const result = await client.accountBalance();
res.send(result);
});

app.get("/summary", async function (req, res) {
const result = await client.accountSummary();
res.send(result);
});

app.get("/value4value", async function (req, res) {
const result = await client.accountValue4Value();
res.send(result);
});

app.get("/make-invoice", async function (req, res) {
const result = await client.createInvoice({amount: 1000});
res.send(result);
});

app.get("/bolt11/:invoice", async function(req, res) {
const result = await client.sendPayment({invoice: req.params.invoice});
res.send(result);
});

app.get('/keysend/:destination', async function(req, res) {
const result = await client.keysend({
destination: req.params.destination,
amount: 10,
memo: req.query.memo
});
res.send(result);
});

app.get("/refresh", async function (req, res) {
try {
await authClient.refreshAccessToken();
res.send("Refreshed Access Token");
} catch (error) {
console.log(error);
}
});

app.listen(8080, () => {
console.log(`Go here to login: http://localhost:8080/login`);
});
37 changes: 37 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "alby-js-sdk",
"version": "1.0.0",
"description": "Alby OAuth2 Client",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"prebuild": "yarn clean",
"generate": "ts-node scripts/generate.ts",
"prepublishOnly": "yarn test",
"build:watch": "tsc --watch",
"test": "jest",
"clean": "rm -rf dist"
},
"dependencies": {
"crypto-js": "^4.1.1",
"node-fetch": "^2.6.1"
},
"devDependencies": {
"@types/crypto-js": "^4.1.1",
"@types/node-fetch": "^2.6.1",
"express": "^4.18.2"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node"
},
"engines": {
"node": ">=14"
},
"author": "",
"license": "MIT"
}
15 changes: 15 additions & 0 deletions src/OAuth2Bearer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { AuthClient, AuthHeader } from "./types";

export class OAuth2Bearer implements AuthClient {
private bearer_token: string;

constructor(bearer_token: string) {
this.bearer_token = bearer_token;
}

getAuthHeader(): AuthHeader {
return {
Authorization: `Bearer ${this.bearer_token}`,
};
}
}
163 changes: 163 additions & 0 deletions src/OAuth2User.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import sha256 from 'crypto-js/sha256';
import CryptoJS from 'crypto-js';
import Base64 from 'crypto-js/enc-base64';
import { buildQueryString, basicAuthHeader } from "./utils";
import { OAuthClient, AuthHeader, GetTokenResponse, Token, GenerateAuthUrlOptions } from "./types";
import { RequestOptions, rest } from "./request";

const AUTHORIZE_URL = "https://getalby.com/oauth";

export type OAuth2Scopes =
| "account:read"
| "invoices:create"
| "invoices:read"
| "transactions:read"
| "balance:read"
| "payments:send";

export interface OAuth2UserOptions {
client_id: string;
client_secret?: string;
callback: string;
scopes: OAuth2Scopes[];
request_options?: Partial<RequestOptions>;
token?: Token;
}

function processTokenResponse(token: GetTokenResponse): Token {
const { expires_in, ...rest } = token;
return {
...rest,
...(!!expires_in && {
expires_at: Date.now() + expires_in * 1000,
}),
};
}

export class OAuth2User implements OAuthClient {
token?: Token;
#options: OAuth2UserOptions;
#code_verifier?: string;
#code_challenge?: string;
constructor(options: OAuth2UserOptions) {
const { token, ...defaultOptions } = options;
this.#options = {client_secret: '', ...defaultOptions};
this.token = token;
}

/**
* Refresh the access token
*/
async refreshAccessToken(): Promise<{ token: Token }> {
const refresh_token = this.token?.refresh_token;
const { client_id, client_secret, request_options } = this.#options;
if (!client_id) {
throw new Error("client_id is required");
}
if (!refresh_token) {
throw new Error("refresh_token is required");
}
const data = await rest<GetTokenResponse>({
...request_options,
endpoint: `/oauth/token`,
params: {
client_id,
grant_type: "refresh_token",
refresh_token,
},
method: "POST",
headers: {
...request_options?.headers,
"Content-type": "application/x-www-form-urlencoded",
...{
Authorization: basicAuthHeader(client_id, client_secret),
},
},
});
const token = processTokenResponse(data);
this.token = token;
return { token };
}

/**
* Check if an access token is expired
*/
isAccessTokenExpired(): boolean {
const refresh_token = this.token?.refresh_token;
const expires_at = this.token?.expires_at;
if (!expires_at) return true;
return !!refresh_token && expires_at <= Date.now() + 1000;
}

/**
* Request an access token
*/
async requestAccessToken(code?: string): Promise<{ token: Token }> {
const { client_id, client_secret, callback, request_options } =
this.#options;
const code_verifier = this.#code_verifier;
if (!client_id) {
throw new Error("client_id is required");
}
if (!callback) {
throw new Error("callback is required");
}
const params = {
code,
grant_type: "authorization_code",
code_verifier,
client_id,
redirect_uri: callback,
};
const data = await rest<GetTokenResponse>({
...request_options,
endpoint: `/oauth/token`,
params,
method: "POST",
headers: {
...request_options?.headers,
"Content-Type": "application/x-www-form-urlencoded",
...{
Authorization: basicAuthHeader(client_id, client_secret),
},
},
});
const token = processTokenResponse(data);
this.token = token;
return { token };
}

generateAuthURL(options: GenerateAuthUrlOptions): string {
const { client_id, callback, scopes } = this.#options;
if (!callback) throw new Error("callback required");
if (!scopes) throw new Error("scopes required");
if (options.code_challenge_method === "S256") {
const code_verifier = CryptoJS.lib.WordArray.random(64);
this.#code_verifier = code_verifier.toString();
this.#code_challenge = sha256(this.#code_verifier).toString(Base64).replace(/\+/g, '-').replace(/\//g, '_').replace(/\=+$/, '')
} else {
this.#code_challenge = options.code_challenge;
this.#code_verifier = options.code_challenge;
}
const code_challenge = this.#code_challenge;
const url = new URL(AUTHORIZE_URL);
url.search = buildQueryString({
...options,
client_id,
scope: scopes.join(" "),
response_type: "code",
redirect_uri: callback,
code_challenge_method: options.code_challenge_method || "plain",
code_challenge,
});
return url.toString();
}

async getAuthHeader(): Promise<AuthHeader> {
if (!this.token?.access_token) throw new Error("access_token is required");
if (this.isAccessTokenExpired()) await this.refreshAccessToken();
return {
Authorization: `Bearer ${this.token.access_token}`,
};
}
}
Loading

0 comments on commit 470ab58

Please sign in to comment.