Skip to content

Commit

Permalink
feat: add auth login and access token handling (#24)
Browse files Browse the repository at this point in the history
  • Loading branch information
bompo authored Nov 7, 2024
1 parent 601c14a commit 13129b2
Show file tree
Hide file tree
Showing 7 changed files with 284 additions and 39 deletions.
93 changes: 86 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,20 @@
# Derived from https://semantic-release.gitbook.io/semantic-release/recipes/ci-configurations/github-actions

name: release

on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]

permissions:
contents: read # for checkout

jobs:
build-and-release:
build:
runs-on: ubuntu-latest
permissions:
contents: write # to be able to publish a GitHub release
issues: write # to be able to comment on released issues
pull-requests: write # to be able to comment on released pull requests
id-token: write # to enable use of OIDC for npm provenance
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -32,10 +30,91 @@ jobs:
- name: Verify the integrity of provenance attestations and registry signatures for installed dependencies
run: npm audit signatures
- name: Build
env:
NOVA_AUTH0_DEV_CLIENT_ID: ${{ secrets.NOVA_AUTH0_DEV_CLIENT_ID }}
NOVA_AUTH0_STG_CLIENT_ID: ${{ secrets.NOVA_AUTH0_STG_CLIENT_ID }}
NOVA_AUTH0_PROD_CLIENT_ID: ${{ secrets.NOVA_AUTH0_PROD_CLIENT_ID }}
run: npm run build
# semantic-release skips non-configured branches or pull-requests
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: dist/

build-and-release:
runs-on: ubuntu-latest
needs: build
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
permissions:
contents: write # to be able to publish a GitHub release
issues: write # to be able to comment on released issues
pull-requests: write # to be able to comment on released pull requests
id-token: write # to enable use of OIDC for npm provenance
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: build-artifacts
path: dist/
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npx semantic-release

publish-snapshot:
runs-on: ubuntu-latest
needs: build
if: ${{ github.event_name == 'pull_request' }}
permissions:
contents: write # to be able to publish a GitHub release
issues: write # to be able to comment on released issues
pull-requests: write # to be able to comment on released pull requests
id-token: write # to enable use of OIDC for npm provenance
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: build-artifacts
path: dist/
- name: Configure npm authentication
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
- name: Fetch latest release version
id: fetch_latest_release
run: |
LATEST_VERSION=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r .tag_name)
echo "LATEST_VERSION=$LATEST_VERSION" >> $GITHUB_ENV
- name: Update package.json version
env:
LATEST_VERSION: ${{ env.LATEST_VERSION }}
run: |
# Extract the pull request number
PR_NUMBER=$(echo $GITHUB_REF | sed 's/refs\/pull\/\([0-9]*\)\/.*/\1/')
# Extract the branch name
BRANCH_NAME=$(echo $GITHUB_HEAD_REF | sed 's/\//-/g')
# Get the short commit SHA
COMMIT_SHA=$(echo $GITHUB_SHA | cut -c1-7)
# Create a snapshot version with branch name, PR number, and commit SHA
SNAPSHOT_VERSION="${LATEST_VERSION}-pr.${BRANCH_NAME}.${PR_NUMBER}.${COMMIT_SHA}"
# Update the package.json with the new version
jq --arg version "$SNAPSHOT_VERSION" '.version = $version' package.json > package.tmp.json && mv package.tmp.json package.json
- name: Publish snapshot to npm
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
# Publish the snapshot version
npm publish --tag snapshot
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"scripts": {
"dev:pack": "nodemon -w \".\" -e ts -i *.tgz -i dist -x \"npm run build && npm pack\"",
"tsc": "tsc --pretty --noEmit",
"build": "tsup src/index.ts --format esm,cjs --clean --sourcemap && tsc --declaration --emitDeclarationOnly",
"build": "tsup src/index.ts --format esm,cjs --clean --sourcemap --env.NOVA_AUTH0_DEV_CLIENT_ID=$NOVA_AUTH0_DEV_CLIENT_ID --env.NOVA_AUTH0_STG_CLIENT_ID=$NOVA_AUTH0_STG_CLIENT_ID --env.NOVA_AUTH0_PROD_CLIENT_ID=$NOVA_AUTH0_PROD_CLIENT_ID && tsc --declaration --emitDeclarationOnly",
"test": "npm run build && vitest run"
},
"repository": {
Expand All @@ -43,6 +43,7 @@
"ws": "^8.18.0"
},
"dependencies": {
"@auth0/auth0-spa-js": "^2.1.3",
"@types/three": "^0.167.1",
"@wandelbots/wandelbots-api-client": "^24.4.0",
"axios": "^1.7.2",
Expand Down
97 changes: 97 additions & 0 deletions src/LoginWithAuth0.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Auth0Client } from "@auth0/auth0-spa-js"

const DOMAIN_SUFFIX = "wandelbots.io"

/** Mapping of stages to Auth0 configurations */
const auth0ConfigMap = {
dev: {
domain: `https://auth.portal.dev.${DOMAIN_SUFFIX}`,
clientId: process.env.NOVA_AUTH0_DEV_CLIENT_ID,
},
stg: {
domain: `https://auth.portal.stg.${DOMAIN_SUFFIX}`,
clientId: process.env.NOVA_AUTH0_STG_CLIENT_ID,
},
prod: {
domain: `https://auth.portal.${DOMAIN_SUFFIX}`,
clientId: process.env.NOVA_AUTH0_PROD_CLIENT_ID,
},
}

/** Determine which Auth0 configuration to use based on instance URL */
const getAuth0Config = (instanceUrl: string) => {
if (instanceUrl.includes(`dev.${DOMAIN_SUFFIX}`)) return auth0ConfigMap.dev
if (instanceUrl.includes(`stg.${DOMAIN_SUFFIX}`)) return auth0ConfigMap.stg
if (instanceUrl.includes(DOMAIN_SUFFIX)) return auth0ConfigMap.prod
throw new Error(
"Unsupported instance URL. Cannot determine Auth0 configuration.",
)
}

/**
* Checks if login is required based on the instance URL.
*/
export const isLoginRequired = (instanceUrl: string): boolean => {
return instanceUrl.includes(DOMAIN_SUFFIX)
}

export const isDeployedOnPortalInstance = (): boolean => {
return (
typeof window !== "undefined" &&
window.location.origin.includes(DOMAIN_SUFFIX)
)
}

/**
* Initializes Auth0 login process using redirect if necessary and retrieves an access token.
* Stops the process if login is not required or if NOVA_USERNAME and NOVA_PASSWORD are set.
*/
export const loginWithAuth0 = async (
instanceUrl: string,
forceAuthLogin: boolean = false,
): Promise<string | null> => {
if (typeof window === "undefined") {
throw new Error(
"Window object is not available. Cannot perform login flow.",
)
}

if (!forceAuthLogin) {
if (!isLoginRequired(instanceUrl) || isDeployedOnPortalInstance()) {
console.log("Login not required for this instance.")
return null
}
}

const auth0Config = getAuth0Config(instanceUrl)
const auth0Client = new Auth0Client({
domain: auth0Config.domain,
clientId: auth0Config.clientId ?? "",
useRefreshTokens: false,
authorizationParams: {
audience: "nova-instance-rest-api",
redirect_uri: window.location.origin,
},
})

// If the URL includes a redirect result, handle it
if (
window.location.search.includes("code=") &&
window.location.search.includes("state=")
) {
const { appState } = await auth0Client.handleRedirectCallback()
// Return to the URL the user was originally on before the redirect
window.history.replaceState(
{},
document.title,
appState?.returnTo || window.location.pathname,
)
} else {
// Initiate login with redirect
await auth0Client.loginWithRedirect()
}

// Once logged in, retrieve the access token silently
const accessToken = await auth0Client.getTokenSilently()
return accessToken
}
82 changes: 62 additions & 20 deletions src/NovaClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Configuration } from "@wandelbots/wandelbots-api-client"
import type { Configuration as BaseConfiguration } from "@wandelbots/wandelbots-api-client"
import type { AxiosRequestConfig } from "axios"
import axios from "axios"
import urlJoin from "url-join"
import { loginWithAuth0 } from "./LoginWithAuth0.js"
import { AutoReconnectingWebsocket } from "./lib/AutoReconnectingWebsocket"
import { ConnectedMotionGroup } from "./lib/ConnectedMotionGroup"
import { JoggerConnection } from "./lib/JoggerConnection"
Expand All @@ -23,14 +25,21 @@ export type NovaClientConfig = {

/**
* Username for basic auth to the Nova instance.
* @deprecated use accessToken instead
*/
username?: string

/**
* Password for basic auth to the Nova instance.
* @deprecated use accessToken instead
*/
password?: string
} & Omit<Configuration, "isJsonMime" | "basePath">

/**
* Access token for Bearer authentication.
*/
accessToken?: string
} & Omit<BaseConfiguration, "isJsonMime" | "basePath">

type NovaClientConfigWithDefaults = NovaClientConfig & { cellId: string }

Expand All @@ -53,11 +62,28 @@ export class NovaClient {
this.mock = new MockNovaInstance()
}

// Set up Axios instance with interceptor for token fetching
const axiosInstance = axios.create({
baseURL: urlJoin(this.config.instanceUrl, "/api/v1"),
headers: this.getInitialHeaders(config),
})

axiosInstance.interceptors.request.use(async (request) => {
if (!request.headers.Authorization) {
const token = await this.fetchTokenIfNeeded()
if (token) {
request.headers.Authorization = `Bearer ${token}`
}
}
return request
})

this.api = new NovaCellAPIClient(cellId, {
...config,
basePath: urlJoin(this.config.instanceUrl, "/api/v1"),
// Weird isJsonMime thing to work around bug in autogenerated API types
isJsonMime: undefined as any,
isJsonMime: (mime: string) => {
return mime === "application/json"
},
baseOptions: {
...(this.mock
? ({
Expand All @@ -67,23 +93,37 @@ export class NovaClient {
} satisfies AxiosRequestConfig)
: {}),
...config.baseOptions,
// Add basic auth to all axios requests if username and password are provided
...(config.username && config.password
? ({
headers: {
Authorization:
"Basic " + btoa(config.username + ":" + config.password),
},
} satisfies AxiosRequestConfig)
: {}),
},
axiosInstance,
})
}

/**
* Given a relative path on the Nova API for this cell, returns an absolute url
* string suitable for websocket construction.
*/
private getInitialHeaders(config: NovaClientConfig): Record<string, string> {
const headers: Record<string, string> = {}
if (config.accessToken) {
headers.Authorization = `Bearer ${config.accessToken}`
} else if (config.username && config.password) {
headers.Authorization = `Basic ${btoa(config.username + ":" + config.password)}`
}
return headers
}

private async fetchTokenIfNeeded(): Promise<string | null> {
if (this.config.accessToken) {
return this.config.accessToken
}
try {
const token = await loginWithAuth0(this.config.instanceUrl)
if (token) {
this.config.accessToken = token
return token
}
} catch (error) {
console.error("Failed to fetch token:", error)
}
return null
}

makeWebsocketURL(path: string): string {
const url = new URL(
urlJoin(
Expand All @@ -96,9 +136,11 @@ export class NovaClient {
url.protocol = url.protocol.replace("https", "wss")

// If provided, add basic auth credentials to the URL
// TODO - basic auth is deprecated on websockets and doesn't work in Safari
// need a better solution on the backend
if (this.config.username && this.config.password) {
// NOTE - basic auth is deprecated on websockets and doesn't work in Safari
// use tokens instead
if (this.config.accessToken) {
url.searchParams.append("token", this.config.accessToken)
} else if (this.config.username && this.config.password) {
url.username = this.config.username
url.password = this.config.password
}
Expand Down
Loading

0 comments on commit 13129b2

Please sign in to comment.