Skip to content

Commit

Permalink
feat(inception): initial working version
Browse files Browse the repository at this point in the history
  • Loading branch information
djMax committed Oct 11, 2023
0 parents commit e51aaf9
Show file tree
Hide file tree
Showing 13 changed files with 6,737 additions and 0 deletions.
42 changes: 42 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Node.js Package

on:
push:
branches:
- main

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

- uses: actions/setup-node@v3
with:
node-version: 20
cache: yarn
- run: yarn install --immutable
- run: yarn build
- run: yarn lint
- run: yarn test


publish-npm:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1

- uses: actions/setup-node@v3
with:
node-version: 20
cache: yarn
- run: yarn install --immutable
- run: yarn build

- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.SESAMECARE_OSS_NPM_TOKEN }}
run: |
yarn dlx semantic-release
24 changes: 24 additions & 0 deletions .github/workflows/pull_requests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Node CI

on: [push]

jobs:
build:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- name: Use Node.js 18
uses: actions/setup-node@v3
with:
node-version: 20
cache: yarn
- name: npm install, lint, build, and test
run: |
yarn install --immutable
yarn lint
yarn build
yarn test
env:
CI: true
67 changes: 67 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Custom
src/generated
.transpiled
.nyc_output
.eslintcache

# TypeScript incremental compilation cache
*.tsbuildinfo

# Stock
*.seed
*.log
*.csv
*.dat
*.out
*.pid
*.gz
*.orig

work
/build
pids
logs
results
coverage
lib-cov
html-report
xunit.xml
node_modules
npm-debug.log

.project
.idea
.settings
.iml
*.sublime-workspace
*.sublime-project

.DS_Store*
ehthumbs.db
Icon?
Thumbs.db
.AppleDouble
.LSOverride
.Spotlight-V100
.Trashes

.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

.node_repl_history

# TypeScript incremental compilation cache
*.tsbuildinfo
# Added by coconfig
.eslintignore
.npmignore
tsconfig.json
tsconfig.build.json
.prettierrc.js
.eslintrc.js
.commitlintrc.json
vitest.config.ts
541 changes: 541 additions & 0 deletions .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs

Large diffs are not rendered by default.

874 changes: 874 additions & 0 deletions .yarn/releases/yarn-3.6.4.cjs

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
nodeLinker: node-modules

plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"

yarnPath: .yarn/releases/yarn-3.6.4.cjs
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# fetch-with-token

A simple wrapper around fetch that manages getting a token (typically oauth2 token or other refresh-able token)
and updating it atomically when it expires or stops working.

You just need to write the code to get a new token and the code to analyze whether a retry is required.

```typescript
import { createFetchFunction } from '@sesamecare-oss/fetch-with-token';

const partnerFetch = createFetchFunction({
async getToken() {
const body = await fetch('https://auth.partner.com?clientId=foo&secret=bar').then((r) => r.json());
return {
value: body.access_token,
expiration: new Date(Date.now() + body.expires_in),
};
},
});

// This call will go out with an authorization header
const response = await partnerFetch('https://partner.com');
```
68 changes: 68 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"name": "@sesamecare-oss/fetch-with-token",
"version": "1.0.0",
"description": "A simple wrapper around fetch that manages a token such as an oauth2 token and atomic refresh of expired tokens.",
"main": "build/index.js",
"types": "build/index.d.ts",
"author": "Developers <developers@sesamecare.com>",
"license": "UNLICENSED",
"packageManager": "yarn@3.6.4",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"clean": "yarn dlx rimraf ./dist",
"lint": "eslint .",
"postinstall": "coconfig",
"test": "vitest"
},
"keywords": [
"typescript",
"sesame",
"oauth",
"fetch"
],
"repository": {
"type": "git",
"url": "git+https://github.com/sesamecare/tokenized-fetch.git"
},
"publishConfig": {
"access": "public"
},
"release": {
"branches": [
"main"
],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
[
"@semantic-release/exec",
{
"publishCmd": "yarn dlx pinst --disable"
}
],
"@semantic-release/npm",
"@semantic-release/git"
]
},
"config": {
"coconfig": "@openapi-typescript-infra/coconfig"
},
"devDependencies": {
"@openapi-typescript-infra/coconfig": "^4.2.1",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@types/node": "^20.8.4",
"@typescript-eslint/eslint-plugin": "^6.7.5",
"@typescript-eslint/parser": "^6.7.5",
"coconfig": "^0.13.3",
"eslint": "^8.51.0",
"eslint-config-prettier": "^9.0.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.28.1",
"oauth2-mock-server": "^7.0.0",
"typescript": "^5.2.2",
"vitest": "^0.34.6"
}
}
60 changes: 60 additions & 0 deletions src/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { AbstractToken, FetchArugments, FetchWithTokenOptions } from './types';

function defaultOnFetch(token: AbstractToken, args: FetchArugments): FetchArugments {
return {
request: args.request,
init: {
...args.init,
headers: {
...args.init?.headers,
Authorization: `Bearer ${token.value}`,
},
},
};
}

function defaultShouldReauthenticate(response: Response, retryCount: number): boolean {
return response.status === 401 && retryCount < 2;
}

export function createFetchFunction<TokenType extends AbstractToken = AbstractToken>({
getToken,
onFetch = defaultOnFetch,
shouldReauthenticate = defaultShouldReauthenticate,
}: FetchWithTokenOptions<TokenType>) {
let tokenResolver: Promise<TokenType> | undefined;
let currentToken: TokenType | undefined;

async function resolveToken(force: boolean) {
if (!force && currentToken && currentToken.expiration > new Date()) {
return currentToken;
}
if (!tokenResolver) {
const lastToken = currentToken;
currentToken = undefined;
tokenResolver = getToken(lastToken).then((token) => {
currentToken = token;
tokenResolver = undefined;
return currentToken;
});
}
return tokenResolver;
}

async function fetchWithToken(
request: FetchArugments['request'],
init: FetchArugments['init'],
retryCount: number = 0,
): Promise<Response> {
const token = await resolveToken(false);
const modifiedFetchArguments = onFetch(token, { request, init });
const response = await fetch(modifiedFetchArguments.request, modifiedFetchArguments.init);
if (shouldReauthenticate?.(response, retryCount)) {
await resolveToken(true);
return fetchWithToken(request, init, retryCount + 1);
}
return response;
}

return fetchWithToken as typeof fetch;
}
94 changes: 94 additions & 0 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { OAuth2Server } from 'oauth2-mock-server';

import { createFetchFunction } from './index';

describe('tokenized-fetch', () => {
const server = new OAuth2Server();

beforeAll(async () => {
await server.start(20230, 'localhost');
await server.issuer.keys.generate('RS256');
});

afterAll(async () => {
await server.stop();
});

test('should manage simple oauth token refreshing', async () => {
let getCount = 0;

const fetcher = createFetchFunction({
async getToken() {
getCount += 1;
const response = await fetch('http://localhost:20230/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'urn:read',
}).toString(),
});
const token = await response.json();
return {
value: token.access_token,
expiration: new Date(Date.now() + token.expires_in),
};
},
shouldReauthenticate(response, retryCount) {
return response.status === 401 && retryCount < 2;
},
});

async function introspect() {
return fetcher('http://localhost:20230/introspect', { method: 'POST' }).then(async (response) => {
return {
status: response.status,
json: await response.json(),
};
});
}

await expect(introspect()).resolves.toEqual({ status: 200, json: { active: true } });
expect(getCount, 'Should have called getToken').toBe(1);

await expect(introspect()).resolves.toEqual({ status: 200, json: { active: true } });
expect(getCount, 'Should not have called getToken again').toBe(1);

server.service.once('beforeIntrospect', (introspectResponse) => {
introspectResponse.statusCode = 401;
introspectResponse.body = { active: false };
});

const double = await Promise.all([
introspect(),
introspect(),
]);
expect(getCount, 'should have refreshed the token once').toBe(2);
expect(double).toMatchInlineSnapshot(`
[
{
"json": {
"active": true,
},
"status": 200,
},
{
"json": {
"active": true,
},
"status": 200,
},
]
`);

server.service.on('beforeIntrospect', (introspectResponse) => {
introspectResponse.statusCode = 401;
introspectResponse.body = { active: false };
});
await expect(introspect(), 'should not retry infinitely').resolves.toEqual({ status: 401, json: { active: false } });
expect(getCount, 'should have attempted to refresh 2 more times').toBe(4);
});
});
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './types';
export * from './fetch';
Loading

0 comments on commit e51aaf9

Please sign in to comment.