kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: remove openauth oauth utils from api-client
rodzic
cc28811595
commit
003a2d88ab
|
@ -26,7 +26,6 @@
|
||||||
"@agentic/platform-core": "workspace:*",
|
"@agentic/platform-core": "workspace:*",
|
||||||
"@agentic/platform-types": "workspace:*",
|
"@agentic/platform-types": "workspace:*",
|
||||||
"@standard-schema/spec": "catalog:",
|
"@standard-schema/spec": "catalog:",
|
||||||
"jose": "catalog:",
|
|
||||||
"ky": "catalog:",
|
"ky": "catalog:",
|
||||||
"type-fest": "catalog:"
|
"type-fest": "catalog:"
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,738 +0,0 @@
|
||||||
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
||||||
import {
|
|
||||||
createLocalJWKSet,
|
|
||||||
decodeJwt,
|
|
||||||
errors,
|
|
||||||
type JSONWebKeySet,
|
|
||||||
jwtVerify
|
|
||||||
} from 'jose'
|
|
||||||
|
|
||||||
import type { SubjectSchema } from './subject'
|
|
||||||
import {
|
|
||||||
InvalidAccessTokenError,
|
|
||||||
InvalidAuthorizationCodeError,
|
|
||||||
InvalidRefreshTokenError,
|
|
||||||
InvalidSubjectError
|
|
||||||
} from './errors'
|
|
||||||
import { generatePKCE } from './pkce'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The well-known information for an OAuth 2.0 authorization server.
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
export interface WellKnown {
|
|
||||||
/**
|
|
||||||
* The URI to the JWKS endpoint.
|
|
||||||
*/
|
|
||||||
jwks_uri: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The URI to the token endpoint.
|
|
||||||
*/
|
|
||||||
token_endpoint: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The URI to the authorization endpoint.
|
|
||||||
*/
|
|
||||||
authorization_endpoint: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The tokens returned by the auth server.
|
|
||||||
*/
|
|
||||||
export interface Tokens {
|
|
||||||
/**
|
|
||||||
* The access token.
|
|
||||||
*/
|
|
||||||
access: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The refresh token.
|
|
||||||
*/
|
|
||||||
refresh: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The number of seconds until the access token expires.
|
|
||||||
*/
|
|
||||||
expiresIn: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResponseLike {
|
|
||||||
json(): Promise<unknown>
|
|
||||||
ok: Response['ok']
|
|
||||||
}
|
|
||||||
type FetchLike = (...args: any[]) => Promise<ResponseLike>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The challenge that you can use to verify the code.
|
|
||||||
*/
|
|
||||||
export type Challenge = {
|
|
||||||
/**
|
|
||||||
* The state that was sent to the redirect URI.
|
|
||||||
*/
|
|
||||||
state: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The verifier that was sent to the redirect URI.
|
|
||||||
*/
|
|
||||||
verifier?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure the client.
|
|
||||||
*/
|
|
||||||
export interface AuthClientInput {
|
|
||||||
/**
|
|
||||||
* The client ID. This is just a string to identify your app.
|
|
||||||
*
|
|
||||||
* If you have a web app and a mobile app, you want to use different client IDs both.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* {
|
|
||||||
* clientId: "my-client"
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
clientId: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The URL of your OpenAuth server.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* {
|
|
||||||
* issuer: "https://auth.myserver.com"
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
issuer: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optionally, override the internally used fetch function.
|
|
||||||
*
|
|
||||||
* This is useful if you are using a polyfilled fetch function in your application and you
|
|
||||||
* want the client to use it too.
|
|
||||||
*/
|
|
||||||
fetch?: FetchLike
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthorizeOptions {
|
|
||||||
/**
|
|
||||||
* Enable the PKCE flow. This is for SPA apps.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* {
|
|
||||||
* pkce: true
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
pkce?: boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The provider you want to use for the OAuth flow.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* {
|
|
||||||
* provider: "google"
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* If no provider is specified, the user is directed to a page where they can select from the
|
|
||||||
* list of configured providers.
|
|
||||||
*
|
|
||||||
* If there's only one provider configured, the user will be redirected to that.
|
|
||||||
*/
|
|
||||||
provider?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthorizeResult {
|
|
||||||
/**
|
|
||||||
* The challenge that you can use to verify the code. This is for the PKCE flow for SPA apps.
|
|
||||||
*
|
|
||||||
* This is an object that you _stringify_ and store it in session storage.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* sessionStorage.setItem("challenge", JSON.stringify(challenge))
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
challenge: Challenge
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The URL to redirect the user to. This starts the OAuth flow.
|
|
||||||
*
|
|
||||||
* For example, for SPA apps.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* location.href = url
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returned when the exchange is successful.
|
|
||||||
*/
|
|
||||||
export interface ExchangeSuccess {
|
|
||||||
/**
|
|
||||||
* This is always `false` when the exchange is successful.
|
|
||||||
*/
|
|
||||||
err: false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The access and refresh tokens.
|
|
||||||
*/
|
|
||||||
tokens: Tokens
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returned when the exchange fails.
|
|
||||||
*/
|
|
||||||
export interface ExchangeError {
|
|
||||||
/**
|
|
||||||
* The type of error that occurred. You can handle this by checking the type.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* import { InvalidAuthorizationCodeError } from "@agentic/api-client/error"
|
|
||||||
*
|
|
||||||
* console.log(err instanceof InvalidAuthorizationCodeError)
|
|
||||||
*```
|
|
||||||
*/
|
|
||||||
err: InvalidAuthorizationCodeError
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RefreshOptions {
|
|
||||||
/**
|
|
||||||
* Optionally, pass in the access token.
|
|
||||||
*/
|
|
||||||
access?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returned when the refresh is successful.
|
|
||||||
*/
|
|
||||||
export interface RefreshSuccess {
|
|
||||||
/**
|
|
||||||
* This is always `false` when the refresh is successful.
|
|
||||||
*/
|
|
||||||
err: false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the refreshed tokens only if they've been refreshed.
|
|
||||||
*
|
|
||||||
* If they are still valid, this will be `undefined`.
|
|
||||||
*/
|
|
||||||
tokens?: Tokens
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returned when the refresh fails.
|
|
||||||
*/
|
|
||||||
export interface RefreshError {
|
|
||||||
/**
|
|
||||||
* The type of error that occurred. You can handle this by checking the type.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* import { InvalidRefreshTokenError } from "@agentic/api-client/error"
|
|
||||||
*
|
|
||||||
* console.log(err instanceof InvalidRefreshTokenError)
|
|
||||||
*```
|
|
||||||
*/
|
|
||||||
err: InvalidRefreshTokenError | InvalidAccessTokenError
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VerifyOptions {
|
|
||||||
/**
|
|
||||||
* Optionally, pass in the refresh token.
|
|
||||||
*
|
|
||||||
* If passed in, this will automatically refresh the access token if it has expired.
|
|
||||||
*/
|
|
||||||
refresh?: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
issuer?: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
audience?: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optionally, override the internally used fetch function.
|
|
||||||
*
|
|
||||||
* This is useful if you are using a polyfilled fetch function in your application and you
|
|
||||||
* want the client to use it too.
|
|
||||||
*/
|
|
||||||
fetch?: FetchLike
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VerifyResult<T extends SubjectSchema> {
|
|
||||||
/**
|
|
||||||
* This is always `undefined` when the verify is successful.
|
|
||||||
*/
|
|
||||||
err?: undefined
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the refreshed tokens only if they’ve been refreshed.
|
|
||||||
*
|
|
||||||
* If they are still valid, this will be undefined.
|
|
||||||
*/
|
|
||||||
tokens?: Tokens
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
aud: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The decoded subjects from the access token.
|
|
||||||
*
|
|
||||||
* Has the same shape as the subjects you defined when creating the issuer.
|
|
||||||
*/
|
|
||||||
subject: {
|
|
||||||
[type in keyof T]: {
|
|
||||||
type: type
|
|
||||||
properties: StandardSchemaV1.InferOutput<T[type]>
|
|
||||||
}
|
|
||||||
}[keyof T]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returned when the verify call fails.
|
|
||||||
*/
|
|
||||||
export interface VerifyError {
|
|
||||||
/**
|
|
||||||
* The type of error that occurred. You can handle this by checking the type.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* import { InvalidRefreshTokenError } from "@agentic/api-client/error"
|
|
||||||
*
|
|
||||||
* console.log(err instanceof InvalidRefreshTokenError)
|
|
||||||
*```
|
|
||||||
*/
|
|
||||||
err: InvalidRefreshTokenError | InvalidAccessTokenError
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An instance of the OpenAuth client contains the following methods.
|
|
||||||
*/
|
|
||||||
export interface AuthClient {
|
|
||||||
/**
|
|
||||||
* Start the autorization flow. For example, in SSR sites.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* const { url } = await client.authorize(<redirect_uri>, "code")
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This takes a redirect URI and the type of flow you want to use. The redirect URI is the
|
|
||||||
* location where the user will be redirected to after the flow is complete.
|
|
||||||
*
|
|
||||||
* Supports both the _code_ and _token_ flows. We recommend using the _code_ flow as it's more
|
|
||||||
* secure.
|
|
||||||
*
|
|
||||||
* :::tip
|
|
||||||
* This returns a URL to redirect the user to. This starts the OAuth flow.
|
|
||||||
* :::
|
|
||||||
*
|
|
||||||
* This returns a URL to the auth server. You can redirect the user to the URL to start the
|
|
||||||
* OAuth flow.
|
|
||||||
*
|
|
||||||
* For SPA apps, we recommend using the PKCE flow.
|
|
||||||
*
|
|
||||||
* ```ts {4}
|
|
||||||
* const { challenge, url } = await client.authorize(
|
|
||||||
* <redirect_uri>,
|
|
||||||
* "code",
|
|
||||||
* { pkce: true }
|
|
||||||
* )
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This returns a redirect URL and a challenge that you need to use later to verify the code.
|
|
||||||
*/
|
|
||||||
authorize(
|
|
||||||
redirectUri: string,
|
|
||||||
response: 'code' | 'token',
|
|
||||||
opts?: AuthorizeOptions
|
|
||||||
): Promise<AuthorizeResult>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exchange the code for access and refresh tokens.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* const exchanged = await client.exchange(<code>, <redirect_uri>)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* You call this after the user has been redirected back to your app after the OAuth flow.
|
|
||||||
*
|
|
||||||
* :::tip
|
|
||||||
* For SSR sites, the code is returned in the query parameter.
|
|
||||||
* :::
|
|
||||||
*
|
|
||||||
* So the code comes from the query parameter in the redirect URI. The redirect URI here is
|
|
||||||
* the one that you passed in to the `authorize` call when starting the flow.
|
|
||||||
*
|
|
||||||
* :::tip
|
|
||||||
* For SPA sites, the code is returned through the URL hash.
|
|
||||||
* :::
|
|
||||||
*
|
|
||||||
* If you used the PKCE flow for an SPA app, the code is returned as a part of the redirect URL
|
|
||||||
* hash.
|
|
||||||
*
|
|
||||||
* ```ts {4}
|
|
||||||
* const exchanged = await client.exchange(
|
|
||||||
* <code>,
|
|
||||||
* <redirect_uri>,
|
|
||||||
* <challenge.verifier>
|
|
||||||
* )
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* You also need to pass in the previously stored challenge verifier.
|
|
||||||
*
|
|
||||||
* This method returns the access and refresh tokens. Or if it fails, it returns an error that
|
|
||||||
* you can handle depending on the error.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* import { InvalidAuthorizationCodeError } from "@agentic/api-client/error"
|
|
||||||
*
|
|
||||||
* if (exchanged.err) {
|
|
||||||
* if (exchanged.err instanceof InvalidAuthorizationCodeError) {
|
|
||||||
* // handle invalid code error
|
|
||||||
* }
|
|
||||||
* else {
|
|
||||||
* // handle other errors
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* const { access, refresh } = exchanged.tokens
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
exchange(
|
|
||||||
code: string,
|
|
||||||
redirectUri: string,
|
|
||||||
verifier?: string
|
|
||||||
): Promise<ExchangeSuccess | ExchangeError>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refreshes the tokens if they have expired. This is used in an SPA app to maintain the
|
|
||||||
* session, without logging the user out.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* const next = await client.refresh(<refresh_token>)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Can optionally take the access token as well. If passed in, this will skip the refresh
|
|
||||||
* if the access token is still valid.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* const next = await client.refresh(<refresh_token>, { access: <access_token> })
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This returns the refreshed tokens only if they've been refreshed.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* if (!next.err) {
|
|
||||||
* // tokens are still valid
|
|
||||||
* }
|
|
||||||
* if (next.tokens) {
|
|
||||||
* const { access, refresh } = next.tokens
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Or if it fails, it returns an error that you can handle depending on the error.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* import { InvalidRefreshTokenError } from "@agentic/api-client/error"
|
|
||||||
*
|
|
||||||
* if (next.err) {
|
|
||||||
* if (next.err instanceof InvalidRefreshTokenError) {
|
|
||||||
* // handle invalid refresh token error
|
|
||||||
* }
|
|
||||||
* else {
|
|
||||||
* // handle other errors
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
refresh(
|
|
||||||
refresh: string,
|
|
||||||
opts?: RefreshOptions
|
|
||||||
): Promise<RefreshSuccess | RefreshError>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify the token in the incoming request.
|
|
||||||
*
|
|
||||||
* This is typically used for SSR sites where the token is stored in an HTTP only cookie. And
|
|
||||||
* is passed to the server on every request.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* const verified = await client.verify(<subjects>, <token>)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This takes the subjects that you had previously defined when creating the issuer.
|
|
||||||
*
|
|
||||||
* :::tip
|
|
||||||
* If the refresh token is passed in, it'll automatically refresh the access token.
|
|
||||||
* :::
|
|
||||||
*
|
|
||||||
* This can optionally take the refresh token as well. If passed in, it'll automatically
|
|
||||||
* refresh the access token if it has expired.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* const verified = await client.verify(<subjects>, <token>, { refresh: <refresh_token> })
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This returns the decoded subjects from the access token. And the tokens if they've been
|
|
||||||
* refreshed.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* // based on the subjects you defined earlier
|
|
||||||
* console.log(verified.subject.properties.userID)
|
|
||||||
*
|
|
||||||
* if (verified.tokens) {
|
|
||||||
* const { access, refresh } = verified.tokens
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Or if it fails, it returns an error that you can handle depending on the error.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* import { InvalidRefreshTokenError } from "@agentic/api-client/error"
|
|
||||||
*
|
|
||||||
* if (verified.err) {
|
|
||||||
* if (verified.err instanceof InvalidRefreshTokenError) {
|
|
||||||
* // handle invalid refresh token error
|
|
||||||
* }
|
|
||||||
* else {
|
|
||||||
* // handle other errors
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
verify<T extends SubjectSchema>(
|
|
||||||
subjects: T,
|
|
||||||
token: string,
|
|
||||||
options?: VerifyOptions
|
|
||||||
): Promise<VerifyResult<T> | VerifyError>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an OpenAuth client.
|
|
||||||
*
|
|
||||||
* @param input - Configure the client.
|
|
||||||
*/
|
|
||||||
export function createAuthClient(input: AuthClientInput): AuthClient {
|
|
||||||
const issuer = input.issuer
|
|
||||||
if (!issuer) {
|
|
||||||
throw new Error('No issuer')
|
|
||||||
}
|
|
||||||
|
|
||||||
const jwksCache = new Map<string, ReturnType<typeof createLocalJWKSet>>()
|
|
||||||
const issuerCache = new Map<string, WellKnown>()
|
|
||||||
const f = input.fetch ?? fetch
|
|
||||||
|
|
||||||
async function getIssuer() {
|
|
||||||
const cached = issuerCache.get(issuer!)
|
|
||||||
if (cached) return cached
|
|
||||||
|
|
||||||
const wellKnown = (await f(
|
|
||||||
`${issuer}/.well-known/oauth-authorization-server`
|
|
||||||
).then((r) => r.json())) as WellKnown
|
|
||||||
issuerCache.set(issuer!, wellKnown)
|
|
||||||
|
|
||||||
return wellKnown
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getJWKS() {
|
|
||||||
const wk = await getIssuer()
|
|
||||||
|
|
||||||
const cached = jwksCache.get(issuer!)
|
|
||||||
if (cached) return cached
|
|
||||||
|
|
||||||
const keyset = (await f(wk.jwks_uri).then((r) => r.json())) as JSONWebKeySet
|
|
||||||
const result = createLocalJWKSet(keyset)
|
|
||||||
jwksCache.set(issuer!, result)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const authClient = {
|
|
||||||
async authorize(
|
|
||||||
redirectUri: string,
|
|
||||||
response: 'code' | 'token',
|
|
||||||
opts?: AuthorizeOptions
|
|
||||||
) {
|
|
||||||
const result = new URL(issuer + '/authorize')
|
|
||||||
const challenge: Challenge = { state: crypto.randomUUID() }
|
|
||||||
|
|
||||||
result.searchParams.set('client_id', input.clientId)
|
|
||||||
result.searchParams.set('redirect_uri', redirectUri)
|
|
||||||
result.searchParams.set('response_type', response)
|
|
||||||
result.searchParams.set('state', challenge.state)
|
|
||||||
|
|
||||||
if (opts?.provider) result.searchParams.set('provider', opts.provider)
|
|
||||||
if (opts?.pkce && response === 'code') {
|
|
||||||
const pkce = await generatePKCE()
|
|
||||||
result.searchParams.set('code_challenge_method', 'S256')
|
|
||||||
result.searchParams.set('code_challenge', pkce.challenge)
|
|
||||||
challenge.verifier = pkce.verifier
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
challenge,
|
|
||||||
url: result.toString()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async exchange(
|
|
||||||
code: string,
|
|
||||||
redirectUri: string,
|
|
||||||
verifier?: string
|
|
||||||
): Promise<ExchangeSuccess | ExchangeError> {
|
|
||||||
const tokens = await f(issuer + '/token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
code,
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
client_id: input.clientId,
|
|
||||||
code_verifier: verifier || ''
|
|
||||||
}).toString()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!tokens.ok) {
|
|
||||||
return {
|
|
||||||
err: new InvalidAuthorizationCodeError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = (await tokens.json()) as any
|
|
||||||
return {
|
|
||||||
err: false,
|
|
||||||
tokens: {
|
|
||||||
access: json.access_token as string,
|
|
||||||
refresh: json.refresh_token as string,
|
|
||||||
expiresIn: json.expires_in as number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async refresh(
|
|
||||||
refresh: string,
|
|
||||||
opts?: RefreshOptions
|
|
||||||
): Promise<RefreshSuccess | RefreshError> {
|
|
||||||
if (opts && opts.access) {
|
|
||||||
const decoded = decodeJwt(opts.access)
|
|
||||||
if (!decoded) {
|
|
||||||
return {
|
|
||||||
err: new InvalidAccessTokenError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// allow 30s window for expiration
|
|
||||||
if ((decoded.exp || 0) > Date.now() / 1000 + 30) {
|
|
||||||
return {
|
|
||||||
err: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = await f(issuer + '/token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: 'refresh_token',
|
|
||||||
refresh_token: refresh
|
|
||||||
}).toString()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!tokens.ok) {
|
|
||||||
return {
|
|
||||||
err: new InvalidRefreshTokenError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = (await tokens.json()) as any
|
|
||||||
return {
|
|
||||||
err: false,
|
|
||||||
tokens: {
|
|
||||||
access: json.access_token as string,
|
|
||||||
refresh: json.refresh_token as string,
|
|
||||||
expiresIn: json.expires_in as number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async verify<T extends SubjectSchema>(
|
|
||||||
subjects: T,
|
|
||||||
token: string,
|
|
||||||
options?: VerifyOptions
|
|
||||||
): Promise<VerifyResult<T> | VerifyError> {
|
|
||||||
const jwks = await getJWKS()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await jwtVerify<{
|
|
||||||
mode: 'access'
|
|
||||||
type: keyof T
|
|
||||||
properties: StandardSchemaV1.InferInput<T[keyof T]>
|
|
||||||
}>(token, jwks, {
|
|
||||||
issuer
|
|
||||||
})
|
|
||||||
|
|
||||||
const validated = await subjects[result.payload.type]![
|
|
||||||
'~standard'
|
|
||||||
].validate(result.payload.properties)
|
|
||||||
|
|
||||||
if (!validated.issues && result.payload.mode === 'access') {
|
|
||||||
return {
|
|
||||||
aud: result.payload.aud as string,
|
|
||||||
subject: {
|
|
||||||
type: result.payload.type,
|
|
||||||
properties: validated.value
|
|
||||||
} as any
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
err: new InvalidSubjectError()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof errors.JWTExpired && options?.refresh) {
|
|
||||||
const refreshed = await this.refresh(options.refresh)
|
|
||||||
if (refreshed.err) return refreshed
|
|
||||||
|
|
||||||
const verified = await authClient.verify(
|
|
||||||
subjects,
|
|
||||||
refreshed.tokens!.access,
|
|
||||||
{
|
|
||||||
refresh: refreshed.tokens!.refresh,
|
|
||||||
issuer,
|
|
||||||
fetch: options?.fetch
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (verified.err) return verified
|
|
||||||
|
|
||||||
verified.tokens = refreshed.tokens
|
|
||||||
return verified
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
err: new InvalidAccessTokenError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return authClient
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
// import { authSubjectSchemas } from '@agentic/platform-types'
|
|
||||||
|
|
||||||
// import { createSubjects } from './subject'
|
|
||||||
|
|
||||||
// export const authSubjects = createSubjects(authSubjectSchemas)
|
|
|
@ -1,120 +0,0 @@
|
||||||
/**
|
|
||||||
* A list of errors that can be thrown by OpenAuth.
|
|
||||||
*
|
|
||||||
* You can use these errors to check the type of error and handle it. For example.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* import { InvalidAuthorizationCodeError } from "@agentic/api-client/error"
|
|
||||||
*
|
|
||||||
* if (err instanceof InvalidAuthorizationCodeError) {
|
|
||||||
* // handle invalid code error
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @packageDocumentation
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The OAuth server returned an error.
|
|
||||||
*/
|
|
||||||
export class OauthError extends Error {
|
|
||||||
constructor(
|
|
||||||
public error:
|
|
||||||
| 'invalid_request'
|
|
||||||
| 'invalid_grant'
|
|
||||||
| 'unauthorized_client'
|
|
||||||
| 'access_denied'
|
|
||||||
| 'unsupported_grant_type'
|
|
||||||
| 'server_error'
|
|
||||||
| 'temporarily_unavailable',
|
|
||||||
public description: string
|
|
||||||
) {
|
|
||||||
super(error + ' - ' + description)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `provider` needs to be passed in.
|
|
||||||
*/
|
|
||||||
export class MissingProviderError extends OauthError {
|
|
||||||
constructor() {
|
|
||||||
super(
|
|
||||||
'invalid_request',
|
|
||||||
'Must specify `provider` query parameter if `select` callback on issuer is not specified'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The given parameter is missing.
|
|
||||||
*/
|
|
||||||
export class MissingParameterError extends OauthError {
|
|
||||||
constructor(public parameter: string) {
|
|
||||||
super('invalid_request', 'Missing parameter: ' + parameter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The given client is not authorized to use the redirect URI that was passed in.
|
|
||||||
*/
|
|
||||||
export class UnauthorizedClientError extends OauthError {
|
|
||||||
constructor(
|
|
||||||
public clientID: string,
|
|
||||||
redirectURI: string
|
|
||||||
) {
|
|
||||||
super(
|
|
||||||
'unauthorized_client',
|
|
||||||
`Client ${clientID} is not authorized to use this redirect_uri: ${redirectURI}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The browser was in an unknown state.
|
|
||||||
*
|
|
||||||
* This can happen when certain cookies have expired. Or the browser was switched in the middle
|
|
||||||
* of the authentication flow.
|
|
||||||
*/
|
|
||||||
export class UnknownStateError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super(
|
|
||||||
'The browser was in an unknown state. This could be because certain cookies expired or the browser was switched in the middle of an authentication flow.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The given subject is invalid.
|
|
||||||
*/
|
|
||||||
export class InvalidSubjectError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super('Invalid subject')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The given refresh token is invalid.
|
|
||||||
*/
|
|
||||||
export class InvalidRefreshTokenError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super('Invalid refresh token')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The given access token is invalid.
|
|
||||||
*/
|
|
||||||
export class InvalidAccessTokenError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super('Invalid access token')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The given authorization code is invalid.
|
|
||||||
*/
|
|
||||||
export class InvalidAuthorizationCodeError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super('Invalid authorization code')
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,41 +0,0 @@
|
||||||
import { base64url } from 'jose'
|
|
||||||
|
|
||||||
function generateVerifier(length: number): string {
|
|
||||||
const buffer = new Uint8Array(length)
|
|
||||||
crypto.getRandomValues(buffer)
|
|
||||||
return base64url.encode(buffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateChallenge(verifier: string, method: 'S256' | 'plain') {
|
|
||||||
if (method === 'plain') return verifier
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
const data = encoder.encode(verifier)
|
|
||||||
const hash = await crypto.subtle.digest('SHA-256', data)
|
|
||||||
return base64url.encode(new Uint8Array(hash))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generatePKCE(length = 64) {
|
|
||||||
if (length < 43 || length > 128) {
|
|
||||||
throw new Error(
|
|
||||||
'Code verifier length must be between 43 and 128 characters'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const verifier = generateVerifier(length)
|
|
||||||
const challenge = await generateChallenge(verifier, 'S256')
|
|
||||||
return {
|
|
||||||
verifier,
|
|
||||||
challenge,
|
|
||||||
method: 'S256'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function validatePKCE(
|
|
||||||
verifier: string,
|
|
||||||
challenge: string,
|
|
||||||
method: 'S256' | 'plain' = 'S256'
|
|
||||||
) {
|
|
||||||
const generatedChallenge = await generateChallenge(verifier, method)
|
|
||||||
// timing safe equals?
|
|
||||||
return generatedChallenge === challenge
|
|
||||||
}
|
|
|
@ -1,130 +0,0 @@
|
||||||
/**
|
|
||||||
* Subjects are what the access token generated at the end of the auth flow will map to. Under
|
|
||||||
* the hood, the access token is a JWT that contains this data.
|
|
||||||
*
|
|
||||||
* #### Define subjects
|
|
||||||
*
|
|
||||||
* ```ts title="subjects.ts"
|
|
||||||
* import { object, string } from "valibot"
|
|
||||||
*
|
|
||||||
* const subjects = createSubjects({
|
|
||||||
* user: object({
|
|
||||||
* userID: string()
|
|
||||||
* })
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* We are using [valibot](https://github.com/fabian-hiller/valibot) here. You can use any
|
|
||||||
* validation library that's following the
|
|
||||||
* [standard-schema specification](https://github.com/standard-schema/standard-schema).
|
|
||||||
*
|
|
||||||
* :::tip
|
|
||||||
* You typically want to place subjects in its own file so it can be imported by all of your apps.
|
|
||||||
* :::
|
|
||||||
*
|
|
||||||
* You can start with one subject. Later you can add more for different types of users.
|
|
||||||
*
|
|
||||||
* #### Set the subjects
|
|
||||||
*
|
|
||||||
* Then you can pass it to the `issuer`.
|
|
||||||
*
|
|
||||||
* ```ts title="issuer.ts"
|
|
||||||
* import { subjects } from "./subjects"
|
|
||||||
*
|
|
||||||
* const app = issuer({
|
|
||||||
* providers: { ... },
|
|
||||||
* subjects,
|
|
||||||
* // ...
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* #### Add the subject payload
|
|
||||||
*
|
|
||||||
* When your user completes the flow, you can add the subject payload in the `success` callback.
|
|
||||||
*
|
|
||||||
* ```ts title="issuer.ts"
|
|
||||||
* const app = issuer({
|
|
||||||
* providers: { ... },
|
|
||||||
* subjects,
|
|
||||||
* async success(ctx, value) {
|
|
||||||
* let userID
|
|
||||||
* if (value.provider === "password") {
|
|
||||||
* console.log(value.email)
|
|
||||||
* userID = ... // lookup user or create them
|
|
||||||
* }
|
|
||||||
* return ctx.subject("user", {
|
|
||||||
* userID
|
|
||||||
* })
|
|
||||||
* },
|
|
||||||
* // ...
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Here we are looking up the userID from our database and adding it to the subject payload.
|
|
||||||
*
|
|
||||||
* :::caution
|
|
||||||
* You should only store properties that won't change for the lifetime of the user.
|
|
||||||
* :::
|
|
||||||
*
|
|
||||||
* Since these will be stored in the access token, you should avoid storing information
|
|
||||||
* that'll change often. For example, if you store the user's username, you'll need to
|
|
||||||
* revoke the access token when the user changes their username.
|
|
||||||
*
|
|
||||||
* #### Decode the subject
|
|
||||||
*
|
|
||||||
* Now when your user logs in, you can use the OpenAuth client to decode the subject. For
|
|
||||||
* example, in our SSR app we can do the following.
|
|
||||||
*
|
|
||||||
* ```ts title="app/page.tsx"
|
|
||||||
* import { subjects } from "../subjects"
|
|
||||||
*
|
|
||||||
* const verified = await client.verify(subjects, cookies.get("access_token")!)
|
|
||||||
* console.log(verified.subject.properties.userID)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* All this is typesafe based on the shape of the subjects you defined.
|
|
||||||
*
|
|
||||||
* @packageDocumentation
|
|
||||||
*/
|
|
||||||
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
||||||
import type { Simplify } from 'type-fest'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subject schema is a map of types that are used to define the subjects.
|
|
||||||
*/
|
|
||||||
export type SubjectSchema = Record<string, StandardSchemaV1>
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
export type SubjectPayload<T extends SubjectSchema> = Simplify<
|
|
||||||
{
|
|
||||||
[type in keyof T & string]: {
|
|
||||||
type: type
|
|
||||||
properties: StandardSchemaV1.InferOutput<T[type]>
|
|
||||||
}
|
|
||||||
}[keyof T & string]
|
|
||||||
>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a subject schema.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const subjects = createSubjects({
|
|
||||||
* user: object({
|
|
||||||
* userID: string()
|
|
||||||
* }),
|
|
||||||
* admin: object({
|
|
||||||
* workspaceID: string()
|
|
||||||
* })
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This is using [valibot](https://github.com/fabian-hiller/valibot) to define the shape of the
|
|
||||||
* subjects. You can use any validation library that's following the
|
|
||||||
* [standard-schema specification](https://github.com/standard-schema/standard-schema).
|
|
||||||
*/
|
|
||||||
export function createSubjects<Schema extends SubjectSchema>(
|
|
||||||
types: Schema
|
|
||||||
): Schema {
|
|
||||||
return { ...types }
|
|
||||||
}
|
|
|
@ -240,9 +240,6 @@ catalogs:
|
||||||
is-obj:
|
is-obj:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
jose:
|
|
||||||
specifier: ^6.0.11
|
|
||||||
version: 6.0.11
|
|
||||||
knip:
|
knip:
|
||||||
specifier: ^5.61.2
|
specifier: ^5.61.2
|
||||||
version: 5.61.2
|
version: 5.61.2
|
||||||
|
@ -868,9 +865,6 @@ importers:
|
||||||
'@standard-schema/spec':
|
'@standard-schema/spec':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
jose:
|
|
||||||
specifier: 'catalog:'
|
|
||||||
version: 6.0.11
|
|
||||||
ky:
|
ky:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 1.8.1
|
version: 1.8.1
|
||||||
|
@ -5647,9 +5641,6 @@ packages:
|
||||||
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
|
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
jose@6.0.11:
|
|
||||||
resolution: {integrity: sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==}
|
|
||||||
|
|
||||||
joycon@3.1.1:
|
joycon@3.1.1:
|
||||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
@ -12310,8 +12301,6 @@ snapshots:
|
||||||
|
|
||||||
jiti@2.4.2: {}
|
jiti@2.4.2: {}
|
||||||
|
|
||||||
jose@6.0.11: {}
|
|
||||||
|
|
||||||
joycon@3.1.1: {}
|
joycon@3.1.1: {}
|
||||||
|
|
||||||
js-cookie@2.2.1: {}
|
js-cookie@2.2.1: {}
|
||||||
|
|
Ładowanie…
Reference in New Issue