Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions generate-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,22 +233,37 @@ ${renderExports(route)}

const renderImports = ({ namespace, subresources }: Route): string =>
`
import type { RouteRequestParams, RouteResponse, RouteRequestBody } from '@seamapi/types/connect'
import { Axios } from 'axios'
import type {
RouteRequestBody,
RouteRequestParams,
RouteResponse,
} from '@seamapi/types/connect'
import type { SetNonNullable } from 'type-fest'

import { createClient } from 'lib/seam/connect/client.js'
import { warnOnInsecureuserIdentifierKey } from 'lib/seam/connect/auth.js'
import {
type Client,
type ClientOptions,
createClient,
} from 'lib/seam/connect/client.js'
import {
isSeamHttpOptionsWithApiKey,
isSeamHttpOptionsWithClient,
isSeamHttpOptionsWithClientSessionToken,
type SeamHttpFromPublishableKeyOptions,
SeamHttpInvalidOptionsError,
type SeamHttpOptions,
type SeamHttpOptionsWithApiKey,
type SeamHttpOptionsWithClient,
type SeamHttpOptionsWithClientSessionToken,
} from 'lib/seam/connect/options.js'
import { parseOptions } from 'lib/seam/connect/parse-options.js'

${
namespace === 'client_sessions'
? ''
: "import { SeamHttpClientSessions } from './client-sessions.js'"
}
${subresources
.map((subresource) => renderSubresourceImport(subresource, namespace))
.join('\n')}
Expand All @@ -268,11 +283,15 @@ const renderClass = (
): string =>
`
export class SeamHttp${pascalCase(namespace)} {
client: Axios
client: Client

${constructors
.replace(/.*this\.#legacy.*\n/, '')
.replaceAll(': SeamHttp ', `: SeamHttp${pascalCase(namespace)} `)
.replaceAll('<SeamHttp>', `<SeamHttp${pascalCase(namespace)}>`)
.replaceAll(
'SeamHttp.fromClientSessionToken',
`SeamHttp${pascalCase(namespace)}.fromClientSessionToken`,
)
.replaceAll('new SeamHttp(', `new SeamHttp${pascalCase(namespace)}(`)}

${subresources
Expand Down
16 changes: 8 additions & 8 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@
"axios-retry": "^3.8.0"
},
"devDependencies": {
"@seamapi/fake-seam-connect": "^1.18.0",
"@seamapi/types": "^1.14.0",
"@seamapi/fake-seam-connect": "^1.21.0",
"@seamapi/types": "^1.24.0",
"@types/eslint": "^8.44.2",
"@types/node": "^18.11.18",
"ava": "^5.0.1",
Expand Down
78 changes: 75 additions & 3 deletions src/lib/seam/connect/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ import {
isSeamHttpOptionsWithApiKey,
isSeamHttpOptionsWithClientSessionToken,
SeamHttpInvalidOptionsError,
type SeamHttpOptions,
type SeamHttpOptionsWithApiKey,
type SeamHttpOptionsWithClientSessionToken,
} from './options.js'
import type { Options } from './parse-options.js'

type Headers = Record<string, string>

export const getAuthHeaders = (options: SeamHttpOptions): Headers => {
export const getAuthHeaders = (options: Options): Headers => {
if ('publishableKey' in options) {
return getAuthHeadersForPublishableKey(options.publishableKey)
}

if (isSeamHttpOptionsWithApiKey(options)) {
return getAuthHeadersForApiKey(options)
}
Expand All @@ -19,7 +23,7 @@ export const getAuthHeaders = (options: SeamHttpOptions): Headers => {
}

throw new SeamHttpInvalidOptionsError(
'Must specify an apiKey or clientSessionToken',
'Must specify an apiKey, clientSessionToken, or publishableKey',
)
}

Expand All @@ -42,6 +46,12 @@ const getAuthHeadersForApiKey = ({
)
}

if (isPublishableKey(apiKey)) {
throw new SeamHttpInvalidTokenError(
'A Publishable Key cannot be used as an apiKey',
)
}

if (!isSeamToken(apiKey)) {
throw new SeamHttpInvalidTokenError(
`Unknown or invalid apiKey format, expected token to start with ${tokenPrefix}`,
Expand All @@ -68,6 +78,12 @@ const getAuthHeadersForClientSessionToken = ({
)
}

if (isPublishableKey(clientSessionToken)) {
throw new SeamHttpInvalidTokenError(
'A Publishable Key cannot be used as a clientSessionToken',
)
}

if (!isClientSessionToken(clientSessionToken)) {
throw new SeamHttpInvalidTokenError(
`Unknown or invalid clientSessionToken format, expected token to start with ${clientSessionTokenPrefix}`,
Expand All @@ -80,6 +96,36 @@ const getAuthHeadersForClientSessionToken = ({
}
}

const getAuthHeadersForPublishableKey = (publishableKey: string): Headers => {
if (isJwt(publishableKey)) {
throw new SeamHttpInvalidTokenError(
'A JWT cannot be used as a publishableKey',
)
}

if (isAccessToken(publishableKey)) {
throw new SeamHttpInvalidTokenError(
'An Access Token cannot be used as a publishableKey',
)
}

if (isClientSessionToken(publishableKey)) {
throw new SeamHttpInvalidTokenError(
'A Client Session Token Key cannot be used as a publishableKey',
)
}

if (!isPublishableKey(publishableKey)) {
throw new SeamHttpInvalidTokenError(
`Unknown or invalid publishableKey format, expected token to start with ${publishableKeyTokenPrefix}`,
)
}

return {
'seam-publishable-key': publishableKey,
}
}

export class SeamHttpInvalidTokenError extends Error {
constructor(message: string) {
super(`SeamHttp received an invalid token: ${message}`)
Expand All @@ -88,10 +134,29 @@ export class SeamHttpInvalidTokenError extends Error {
}
}

export const warnOnInsecureuserIdentifierKey = (
userIdentifierKey: string,
): void => {
if (isEmail(userIdentifierKey)) {
// eslint-disable-next-line no-console
console.warn(
...[
'Using an email for the userIdentifierKey is insecure and may return an error in the future!',
'This is insecure because an email is common knowledge or easily guessed.',
'Use something with sufficient entropy known only to the owner of the client session.',
'For help choosing a user identifier key see',
'https://docs.seam.co/latest/seam-components/overview/get-started-with-client-side-components#3-select-a-user-identifier-key',
],
)
}
}

const tokenPrefix = 'seam_'

const clientSessionTokenPrefix = 'seam_cst'

const publishableKeyTokenPrefix = 'seam_pk'

const isClientSessionToken = (token: string): boolean =>
token.startsWith(clientSessionTokenPrefix)

Expand All @@ -100,3 +165,10 @@ const isAccessToken = (token: string): boolean => token.startsWith('seam_at')
const isJwt = (token: string): boolean => token.startsWith('ey')

const isSeamToken = (token: string): boolean => token.startsWith(tokenPrefix)

const isPublishableKey = (token: string): boolean =>
token.startsWith(publishableKeyTokenPrefix)

// SOURCE: https://stackoverflow.com/a/46181
const isEmail = (value: string): boolean =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
29 changes: 13 additions & 16 deletions src/lib/seam/connect/client.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,24 @@
import axios, { type Axios } from 'axios'
import axiosRetry, { exponentialDelay } from 'axios-retry'
import axios, { type Axios, type AxiosRequestConfig } from 'axios'
import axiosRetry, { type AxiosRetry, exponentialDelay } from 'axios-retry'

import { paramsSerializer } from 'lib/params-serializer.js'

import { getAuthHeaders } from './auth.js'
import {
isSeamHttpOptionsWithClient,
isSeamHttpOptionsWithClientSessionToken,
type SeamHttpOptions,
} from './options.js'
export type Client = Axios

export const createClient = (options: Required<SeamHttpOptions>): Axios => {
if (isSeamHttpOptionsWithClient(options)) return options.client
export interface ClientOptions {
axiosOptions?: AxiosRequestConfig
axiosRetryOptions?: AxiosRetryConfig
client?: Client
}

type AxiosRetryConfig = Parameters<AxiosRetry>[1]

export const createClient = (options: ClientOptions): Axios => {
if (options.client != null) return options.client

const client = axios.create({
baseURL: options.endpoint,
withCredentials: isSeamHttpOptionsWithClientSessionToken(options),
paramsSerializer,
...options.axiosOptions,
headers: {
...getAuthHeaders(options),
...options.axiosOptions.headers,
},
})

axiosRetry(client, {
Expand Down
25 changes: 9 additions & 16 deletions src/lib/seam/connect/options.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,21 @@
import type { Axios, AxiosRequestConfig } from 'axios'
import type { AxiosRetry } from 'axios-retry'
import type { Client, ClientOptions } from './client.js'

export type SeamHttpOptions =
| SeamHttpOptionsFromEnv
| SeamHttpOptionsWithClient
| SeamHttpOptionsWithApiKey
| SeamHttpOptionsWithClientSessionToken

interface SeamHttpCommonOptions {
interface SeamHttpCommonOptions extends ClientOptions {
endpoint?: string
axiosOptions?: AxiosRequestConfig
axiosRetryOptions?: AxiosRetryConfig
enableLegacyMethodBehaivor?: boolean
}

type AxiosRetryConfig = Parameters<AxiosRetry>[1]
export type SeamHttpFromPublishableKeyOptions = SeamHttpCommonOptions

export type SeamHttpOptionsFromEnv = SeamHttpCommonOptions

export interface SeamHttpOptionsWithClient
extends Pick<SeamHttpCommonOptions, 'enableLegacyMethodBehaivor'> {
client: Axios
export interface SeamHttpOptionsWithClient {
client: Client
}

export const isSeamHttpOptionsWithClient = (
Expand All @@ -29,12 +24,10 @@ export const isSeamHttpOptionsWithClient = (
if (!('client' in options)) return false
if (options.client == null) return false

const keys = Object.keys(options).filter(
(k) => !['client', 'enableLegacyMethodBehaivor'].includes(k),
)
const keys = Object.keys(options).filter((k) => k !== 'client')
if (keys.length > 0) {
throw new SeamHttpInvalidOptionsError(
`The client option cannot be used with any other option except enableLegacyMethodBehaivor, but received: ${keys.join(
`The client option cannot be used with any other option, but received: ${keys.join(
', ',
)}`,
)
Expand All @@ -55,7 +48,7 @@ export const isSeamHttpOptionsWithApiKey = (

if ('clientSessionToken' in options && options.clientSessionToken != null) {
throw new SeamHttpInvalidOptionsError(
'The clientSessionToken option cannot be used with the apiKey option.',
'The clientSessionToken option cannot be used with the apiKey option',
)
}

Expand All @@ -75,7 +68,7 @@ export const isSeamHttpOptionsWithClientSessionToken = (

if ('apiKey' in options && options.apiKey != null) {
throw new SeamHttpInvalidOptionsError(
'The clientSessionToken option cannot be used with the apiKey option.',
'The clientSessionToken option cannot be used with the apiKey option',
)
}

Expand Down
Loading