From f9254b5965074e93f9ecda13ece0ef7691e26651 Mon Sep 17 00:00:00 2001 From: Travis Fischer Date: Mon, 16 Jun 2025 08:21:00 +0700 Subject: [PATCH] feat: re-add github auth --- apps/api/package.json | 1 + .../api/src/api-v1/auth/{github => github.ts} | 76 +- apps/api/src/api-v1/index.ts | 2 + apps/api/src/lib/external/github.ts | 2 +- apps/api/src/oauth-redirect.ts | 30 +- apps/api/src/server.ts | 2 + packages/api-client/package.json | 2 + packages/api-client/src/agentic-api-client.ts | 110 ++- packages/api-client/src/auth-client.ts | 738 ++++++++++++++++++ packages/api-client/src/auth-subjects | 5 + packages/api-client/src/errors.ts | 120 +++ packages/api-client/src/pkce.ts | 41 + packages/api-client/src/subject.ts | 130 +++ packages/cli/src/lib/auth.ts | 40 +- packages/types/src/openapi.d.ts | 47 ++ pnpm-lock.yaml | 265 ++++++- pnpm-workspace.yaml | 2 +- 17 files changed, 1532 insertions(+), 81 deletions(-) rename apps/api/src/api-v1/auth/{github => github.ts} (58%) create mode 100644 packages/api-client/src/auth-client.ts create mode 100644 packages/api-client/src/auth-subjects create mode 100644 packages/api-client/src/errors.ts create mode 100644 packages/api-client/src/pkce.ts create mode 100644 packages/api-client/src/subject.ts diff --git a/apps/api/package.json b/apps/api/package.json index cc5496bc..2071915e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -45,6 +45,7 @@ "bcryptjs": "^3.0.2", "exit-hook": "catalog:", "hono": "catalog:", + "ky": "catalog:", "octokit": "catalog:", "p-all": "catalog:", "postgres": "catalog:", diff --git a/apps/api/src/api-v1/auth/github b/apps/api/src/api-v1/auth/github.ts similarity index 58% rename from apps/api/src/api-v1/auth/github rename to apps/api/src/api-v1/auth/github.ts index 229e2692..5f5921bf 100644 --- a/apps/api/src/api-v1/auth/github +++ b/apps/api/src/api-v1/auth/github.ts @@ -1,24 +1,25 @@ +import type { DefaultHonoEnv } from '@agentic/platform-hono' import { assert, parseZodSchema } from '@agentic/platform-core' -import { isValidPassword } from '@agentic/platform-validators' import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' -import type { AuthenticatedHonoEnv } from '@/lib/types' -import { type RawAccount, usernameSchema } from '@/db' import { createAuthToken } from '@/lib/auth/create-auth-token' import { upsertOrLinkUserAccount } from '@/lib/auth/upsert-or-link-user-account' -import { getGitHubClient } from '@/lib/external/github' +import { + exchangeGitHubOAuthCodeForAccessToken, + getGitHubClient +} from '@/lib/external/github' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' -import { userAuthResponseSchema } from './schemas' +import { authSessionResponseSchema } from './schemas' const route = createRoute({ - description: 'Authenticates with GitHub.', + description: 'Exchanges a GitHub OAuth code for an Agentic auth session.', tags: ['auth'], - operationId: 'authWithGitHub', + operationId: 'exchangeOAuthCodeWithGitHub', method: 'post', path: 'auth/github', security: openapiAuthenticatedSecuritySchemas, @@ -27,21 +28,21 @@ const route = createRoute({ required: true, content: { 'application/json': { - schema: z.object({ - username: usernameSchema, - email: z.string().email(), - password: z.string().refine((password) => isValidPassword(password)) - }) + schema: z + .object({ + code: z.string() + }) + .passthrough() } } } }, responses: { 200: { - description: 'A user object', + description: 'An auth session', content: { 'application/json': { - schema: userAuthResponseSchema + schema: authSessionResponseSchema } } }, @@ -50,15 +51,21 @@ const route = createRoute({ } }) -export function registerV1AuthWithGitHub( - app: OpenAPIHono +export function registerV1AuthExchangeOAuthCodeWithGitHub( + app: OpenAPIHono ) { return app.openapi(route, async (c) => { + const logger = c.get('logger') const body = c.req.valid('json') - const client = getGitHubClient({ accessToken: value.tokenset.access }) + const result = await exchangeGitHubOAuthCodeForAccessToken(body) + logger.info('github oauth', result) + + const client = getGitHubClient({ accessToken: result.access_token! }) const { data: ghUser } = await client.rest.users.getAuthenticated() + logger.info('github user', ghUser) + if (!ghUser.email) { const { data: emails } = await client.request('GET /user/emails') const primary = emails.find((e) => e.primary) @@ -73,29 +80,22 @@ export function registerV1AuthWithGitHub( 'Error authenticating with GitHub: user email is required.' ) - function getPartialOAuthAccount(): Partial { - const now = Date.now() - - return { - provider: 'github', - accessToken: value.tokenset.access, - refreshToken: value.tokenset.refresh, - // `expires_in` and `refresh_token_expires_in` are given in seconds - accessTokenExpiresAt: new Date( - now + value.tokenset.raw.expires_in * 1000 - ), - refreshTokenExpiresAt: new Date( - now + value.tokenset.raw.refresh_token_expires_in * 1000 - ), - scope: (value.tokenset.raw.scope as string) || undefined - } - } - + const now = Date.now() const user = await upsertOrLinkUserAccount({ partialAccount: { + provider: 'github', accountId: `${ghUser.id}`, accountUsername: ghUser.login.toLowerCase(), - ...getPartialOAuthAccount() + accessToken: result.access_token, + refreshToken: result.refresh_token, + // `expires_in` and `refresh_token_expires_in` are given in seconds + accessTokenExpiresAt: result.expires_in + ? new Date(now + result.expires_in * 1000) + : undefined, + refreshTokenExpiresAt: result.refresh_token_expires_in + ? new Date(now + result.refresh_token_expires_in * 1000) + : undefined, + scope: result.scope || undefined }, partialUser: { email: ghUser.email, @@ -106,7 +106,9 @@ export function registerV1AuthWithGitHub( } }) + logger.info('github user result', user) + const token = await createAuthToken(user) - return c.json(parseZodSchema(userAuthResponseSchema, { token, user })) + return c.json(parseZodSchema(authSessionResponseSchema, { token, user })) }) } diff --git a/apps/api/src/api-v1/index.ts b/apps/api/src/api-v1/index.ts index 597cfd4a..8cf4b9ac 100644 --- a/apps/api/src/api-v1/index.ts +++ b/apps/api/src/api-v1/index.ts @@ -6,6 +6,7 @@ import type { AuthenticatedHonoEnv } from '@/lib/types' import * as middleware from '@/lib/middleware' import { registerOpenAPIErrorResponses } from '@/lib/openapi-utils' +import { registerV1AuthExchangeOAuthCodeWithGitHub } from './auth/github' import { registerV1AuthSignInWithPassword } from './auth/sign-in-with-password' import { registerV1AuthSignUpWithPassword } from './auth/sign-up-with-password' import { registerV1AdminConsumersActivateConsumer } from './consumers/admin-activate-consumer' @@ -77,6 +78,7 @@ registerHealthCheck(publicRouter) // Auth registerV1AuthSignInWithPassword(publicRouter) registerV1AuthSignUpWithPassword(publicRouter) +registerV1AuthExchangeOAuthCodeWithGitHub(publicRouter) // Users registerV1UsersGetUser(privateRouter) diff --git a/apps/api/src/lib/external/github.ts b/apps/api/src/lib/external/github.ts index 8acbdc66..381d2f02 100644 --- a/apps/api/src/lib/external/github.ts +++ b/apps/api/src/lib/external/github.ts @@ -58,7 +58,7 @@ export function getGitHubClient({ return new Octokit({ auth: accessToken }) } -export async function exchangeOAuthCodeForAccessToken({ +export async function exchangeGitHubOAuthCodeForAccessToken({ code, clientId = env.GITHUB_CLIENT_ID, clientSecret = env.GITHUB_CLIENT_SECRET, diff --git a/apps/api/src/oauth-redirect.ts b/apps/api/src/oauth-redirect.ts index 1968c8a0..62c8065a 100644 --- a/apps/api/src/oauth-redirect.ts +++ b/apps/api/src/oauth-redirect.ts @@ -1,8 +1,11 @@ +import type { DefaultHonoEnv } from '@agentic/platform-hono' import type { OpenAPIHono } from '@hono/zod-openapi' import { assert } from '@agentic/platform-core' -export function registerV1OAuthRedirect(app: OpenAPIHono) { - return app.all('oauth', async (ctx) => { +export function registerOAuthRedirect(app: OpenAPIHono) { + return app.all('/oauth/callback', async (ctx) => { + const logger = ctx.get('logger') + if (ctx.req.query('state')) { const { state: state64, ...query } = ctx.req.query() @@ -21,8 +24,17 @@ export function registerV1OAuthRedirect(app: OpenAPIHono) { ...state, ...query }) + const redirectUri = `${uri}?${searchParams.toString()}` - ctx.redirect(`${uri}?${searchParams.toString()}`) + logger.info( + 'OAUTH CALLBACK', + ctx.req.method, + ctx.req.url, + ctx.req.query(), + '=>', + redirectUri + ) + return ctx.redirect(redirectUri) } else { // github oauth // https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps/#redirect-urls @@ -35,7 +47,17 @@ export function registerV1OAuthRedirect(app: OpenAPIHono) { ) const searchParams = new URLSearchParams(params) - ctx.redirect(`${uri}?${searchParams.toString()}`) + const redirectUri = `${uri}?${searchParams.toString()}` + logger.info( + 'OAUTH CALLBACK', + ctx.req.method, + ctx.req.url, + ctx.req.query(), + '=>', + redirectUri + ) + + return ctx.redirect(redirectUri) } }) } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index aad41c3d..3dcb9689 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -10,6 +10,7 @@ import { env } from '@/lib/env' import * as middleware from '@/lib/middleware' import { initExitHooks } from './lib/exit-hooks' +import { registerOAuthRedirect } from './oauth-redirect' export const app = new OpenAPIHono() @@ -31,6 +32,7 @@ app.use(middleware.accessLogger) app.use(middleware.responseTime) // TODO: top-level auth routes +registerOAuthRedirect(app) // Mount all v1 API routes app.route('/v1', apiV1) diff --git a/packages/api-client/package.json b/packages/api-client/package.json index eaa51b9f..a6ac7da1 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -23,6 +23,8 @@ "dependencies": { "@agentic/platform-core": "workspace:*", "@agentic/platform-types": "workspace:*", + "@standard-schema/spec": "^1.0.0", + "jose": "^6.0.11", "ky": "catalog:", "type-fest": "catalog:" }, diff --git a/packages/api-client/src/agentic-api-client.ts b/packages/api-client/src/agentic-api-client.ts index 63d42345..f4eb6e3f 100644 --- a/packages/api-client/src/agentic-api-client.ts +++ b/packages/api-client/src/agentic-api-client.ts @@ -15,6 +15,11 @@ import { assert, sanitizeSearchParams } from '@agentic/platform-core' import defaultKy, { type KyInstance } from 'ky' import type { OnUpdateAuthSessionFunction } from './types' +// import { +// type AuthClient, +// type AuthorizeResult, +// createAuthClient +// } from './auth-client' export class AgenticApiClient { static readonly DEFAULT_API_BASE_URL = 'https://api.agentic.so' @@ -24,6 +29,7 @@ export class AgenticApiClient { public readonly ky: KyInstance public readonly onUpdateAuth?: OnUpdateAuthSessionFunction + // protected _authClient: AuthClient protected _authSession?: AuthSession constructor({ @@ -43,6 +49,11 @@ export class AgenticApiClient { this.apiKey = apiKey this.onUpdateAuth = onUpdateAuth + // this._authClient = createAuthClient({ + // issuer: apiBaseUrl, + // clientId: 'agentic-api-client' + // }) + this.ky = ky.extend({ prefixUrl: apiBaseUrl, @@ -88,6 +99,71 @@ export class AgenticApiClient { this._authSession = structuredClone(authSession) } + // async verifyAuthAndRefreshIfNecessary(): Promise { + // this._ensureNoApiKey() + + // if (!this._authTokens) { + // throw new Error('This method requires authentication.') + // } + + // const verified = await this._authClient.verify( + // authSubjects, + // this._authTokens.access, + // { + // refresh: this._authTokens.refresh + // } + // ) + + // if (verified.err) { + // throw verified.err + // } + + // if (verified.tokens) { + // this._authTokens = verified.tokens + // } + + // this.onUpdateAuth?.({ + // session: this._authTokens, + // user: verified.subject.properties + // }) + + // return verified.subject.properties + // } + + // async exchangeAuthCode({ + // code, + // redirectUri, + // verifier + // }: { + // code: string + // redirectUri: string + // verifier?: string + // }): Promise { + // this._ensureNoApiKey() + // const result = await this._authClient.exchange(code, redirectUri, verifier) + + // if (result.err) { + // throw result.err + // } + + // this._authTokens = result.tokens + // return this.verifyAuthAndRefreshIfNecessary() + // } + + // async initAuthFlow({ + // redirectUri, + // provider + // }: { + // redirectUri: string + // provider: 'github' + // }): Promise { + // this._ensureNoApiKey() + + // return this._authClient.authorize(redirectUri, 'code', { + // provider + // }) + // } + async logout(): Promise { this._authSession = undefined this.onUpdateAuth?.() @@ -106,7 +182,7 @@ export class AgenticApiClient { // searchParams?: OperationParameters<'signInWithPassword'> ): Promise { this._authSession = await this.ky - .post(`v1/auth/password/signin`, { json }) + .post('v1/auth/password/signin', { json }) .json() this.onUpdateAuth?.(this._authSession) @@ -119,13 +195,43 @@ export class AgenticApiClient { // searchParams?: OperationParameters<'signUpWithPassword'> ): Promise { this._authSession = await this.ky - .post(`v1/auth/password/signup`, { json }) + .post('v1/auth/password/signup', { json }) .json() this.onUpdateAuth?.(this._authSession) return this._authSession } + // TODO + async initAuthFlowWithGitHub({ + redirectUri, + scope = 'user:email', + clientId = 'Iv23lizZv3CnggDT7JED' + }: { + redirectUri: string + scope?: string + clientId?: string + }): Promise { + const publicRedirectUri = `${this.apiBaseUrl}/oauth/callback?${new URLSearchParams({ uri: redirectUri }).toString()}` + + const url = new URL('https://github.com/login/oauth/authorize') + url.searchParams.append('client_id', clientId) + url.searchParams.append('scope', scope) + url.searchParams.append('redirect_uri', publicRedirectUri) + + return url.toString() + } + + // TODO + async exchangeOAuthCodeWithGitHub( + json: OperationBody<'exchangeOAuthCodeWithGitHub'> + ): Promise { + this._authSession = await this.ky.post('v1/auth/github', { json }).json() + + this.onUpdateAuth?.(this._authSession) + return this._authSession + } + /** Gets the currently authenticated user. */ async getMe(): Promise { // const user = await this.verifyAuthAndRefreshIfNecessary() diff --git a/packages/api-client/src/auth-client.ts b/packages/api-client/src/auth-client.ts new file mode 100644 index 00000000..2f424df0 --- /dev/null +++ b/packages/api-client/src/auth-client.ts @@ -0,0 +1,738 @@ +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 + ok: Response['ok'] +} +type FetchLike = (...args: any[]) => Promise + +/** + * 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/openauth/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/openauth/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 { + /** + * 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 + } + }[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/openauth/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(, "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( + * , + * "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 + + /** + * Exchange the code for access and refresh tokens. + * + * ```ts + * const exchanged = await client.exchange(, ) + * ``` + * + * 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( + * , + * , + * + * ) + * ``` + * + * 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/openauth/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 + + /** + * 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() + * ``` + * + * 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(, { access: }) + * ``` + * + * 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/openauth/error" + * + * if (next.err) { + * if (next.err instanceof InvalidRefreshTokenError) { + * // handle invalid refresh token error + * } + * else { + * // handle other errors + * } + * } + * ``` + */ + refresh( + refresh: string, + opts?: RefreshOptions + ): Promise + + /** + * 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(, ) + * ``` + * + * 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(, , { refresh: }) + * ``` + * + * 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/openauth/error" + * + * if (verified.err) { + * if (verified.err instanceof InvalidRefreshTokenError) { + * // handle invalid refresh token error + * } + * else { + * // handle other errors + * } + * } + * ``` + */ + verify( + subjects: T, + token: string, + options?: VerifyOptions + ): Promise | 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>() + const issuerCache = new Map() + 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 { + 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 { + 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( + subjects: T, + token: string, + options?: VerifyOptions + ): Promise | VerifyError> { + const jwks = await getJWKS() + + try { + const result = await jwtVerify<{ + mode: 'access' + type: keyof T + properties: StandardSchemaV1.InferInput + }>(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 +} diff --git a/packages/api-client/src/auth-subjects b/packages/api-client/src/auth-subjects new file mode 100644 index 00000000..2aaae8f5 --- /dev/null +++ b/packages/api-client/src/auth-subjects @@ -0,0 +1,5 @@ +// import { authSubjectSchemas } from '@agentic/platform-types' + +// import { createSubjects } from './subject' + +// export const authSubjects = createSubjects(authSubjectSchemas) diff --git a/packages/api-client/src/errors.ts b/packages/api-client/src/errors.ts new file mode 100644 index 00000000..d2849f4e --- /dev/null +++ b/packages/api-client/src/errors.ts @@ -0,0 +1,120 @@ +/** + * 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/openauth/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') + } +} diff --git a/packages/api-client/src/pkce.ts b/packages/api-client/src/pkce.ts new file mode 100644 index 00000000..8b896c04 --- /dev/null +++ b/packages/api-client/src/pkce.ts @@ -0,0 +1,41 @@ +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 +} diff --git a/packages/api-client/src/subject.ts b/packages/api-client/src/subject.ts new file mode 100644 index 00000000..9d418266 --- /dev/null +++ b/packages/api-client/src/subject.ts @@ -0,0 +1,130 @@ +/** + * 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 + +/** @internal */ +export type SubjectPayload = Simplify< + { + [type in keyof T & string]: { + type: type + properties: StandardSchemaV1.InferOutput + } + }[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( + types: Schema +): Schema { + return { ...types } +} diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index b275bae8..4172d746 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -12,8 +12,8 @@ import { oraPromise } from 'ora' import { AuthStore } from './auth-store' const providerToLabel = { - github: 'GitHub', - password: 'email and password' + github: 'GitHub' + // password: 'email and password' } export async function auth({ @@ -22,7 +22,7 @@ export async function auth({ preferredPort = 6013 }: { client: AgenticApiClient - provider: 'github' | 'password' + provider: 'github' // | 'password' preferredPort?: number }): Promise { const providerLabel = providerToLabel[provider] @@ -49,16 +49,23 @@ export async function auth({ const code = c.req.query('code') assert(code, 'Missing required code query parameter') - await client.exchangeAuthCode({ - code, - redirectUri, - verifier: authorizeResult.challenge?.verifier - }) + + await client.exchangeOAuthCodeWithGitHub({ code }) assert( - client.authTokens, + client.authSession, `Error ${providerLabel} auth: failed to exchange auth code for token` ) + // await client.exchangeAuthCode({ + // code, + // redirectUri, + // verifier: authorizeResult.challenge?.verifier + // }) + // assert( + // client.authSession, + // `Error ${providerLabel} auth: failed to exchange auth code for token` + // ) + // AuthStore should be updated via the onUpdateAuth callback const session = AuthStore.tryGetAuth() assert(session && session?.token === client.authSession?.token) @@ -89,13 +96,18 @@ export async function auth({ }) }) - // TODO - const authorizeResult = await client.initAuthFlow({ - provider, + const url = await client.initAuthFlowWithGitHub({ redirectUri }) - assert(authorizeResult.url, `Error signing in with ${providerLabel}`) - await open(authorizeResult.url) + await open(url.toString()) + + // TODO + // const authorizeResult = await client.initAuthFlow({ + // provider, + // redirectUri + // }) + // assert(authorizeResult.url, `Error signing in with ${providerLabel}`) + // await open(authorizeResult.url) const authSession = await oraPromise(authP, { text: `Signing in with ${providerLabel}`, diff --git a/packages/types/src/openapi.d.ts b/packages/types/src/openapi.d.ts index f3ebdc51..c0203375 100644 --- a/packages/types/src/openapi.d.ts +++ b/packages/types/src/openapi.d.ts @@ -55,6 +55,23 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/auth/github": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Exchanges GitHub code for auth session. */ + post: operations["exchangeOAuthCodeWithGitHub"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/users/{userId}": { parameters: { query?: never; @@ -974,6 +991,36 @@ export interface operations { 404: components["responses"]["404"]; }; }; + exchangeOAuthCodeWithGitHub: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + code: string; + }; + }; + }; + responses: { + /** @description An auth session */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthSession"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + }; + }; getUser: { parameters: { query?: never; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f3ca907..b464a021 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,39 +6,256 @@ settings: catalogs: default: + '@apideck/better-ajv-errors': + specifier: ^0.3.6 + version: 0.3.6 + '@clack/prompts': + specifier: ^0.11.0 + version: 0.11.0 + '@cloudflare/workers-types': + specifier: ^4.20250614.0 + version: 4.20250614.0 + '@commander-js/extra-typings': + specifier: ^14.0.0 + version: 14.0.0 + '@edge-runtime/vm': + specifier: ^5.0.0 + version: 5.0.0 + '@fisch0920/config': + specifier: ^1.1.2 + version: 1.1.2 + '@fisch0920/drizzle-orm': + specifier: ^0.43.7 + version: 0.43.7 + '@fisch0920/drizzle-zod': + specifier: ^0.7.9 + version: 0.7.9 + '@hono/node-server': + specifier: ^1.14.4 + version: 1.14.4 + '@hono/sentry': + specifier: ^1.2.2 + version: 1.2.2 + '@hono/zod-openapi': + specifier: ^0.19.8 + version: 0.19.8 + '@hono/zod-validator': + specifier: ^0.7.0 + version: 0.7.0 + '@modelcontextprotocol/sdk': + specifier: ^1.12.3 + version: 1.12.3 + '@paralleldrive/cuid2': + specifier: ^2.2.2 + version: 2.2.2 + '@react-email/components': + specifier: ^0.0.42 + version: 0.0.42 + '@redocly/openapi-core': + specifier: ^1.34.3 + version: 1.34.3 + '@sentry/cli': + specifier: ^2.46.0 + version: 2.46.0 + '@sentry/cloudflare': + specifier: ^9.29.0 + version: 9.29.0 + '@sentry/core': + specifier: ^9.29.0 + version: 9.29.0 + '@sentry/node': + specifier: ^9.29.0 + version: 9.29.0 + '@types/ms': + specifier: ^2.1.0 + version: 2.1.0 + '@types/node': + specifier: ^24.0.1 + version: 24.0.1 '@types/react': specifier: ^19.1.8 version: 19.1.8 '@types/react-dom': specifier: ^19.1.6 version: 19.1.6 + '@types/semver': + specifier: ^7.7.0 + version: 7.7.0 + agents: + specifier: ^0.0.95 + version: 0.0.95 + ajv: + specifier: ^8.17.1 + version: 8.17.1 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1 + camelcase: + specifier: ^8.0.0 + version: 8.0.0 + commander: + specifier: ^14.0.0 + version: 14.0.0 + conf: + specifier: ^14.0.0 + version: 14.0.0 + decamelize: + specifier: ^6.0.0 + version: 6.0.0 + del-cli: + specifier: ^6.0.0 + version: 6.0.0 + drizzle-kit: + specifier: ^0.31.1 + version: 0.31.1 + drizzle-orm: + specifier: ^0.44.2 + version: 0.44.2 + email-validator: + specifier: ^2.0.4 + version: 2.0.4 + eslint: + specifier: ^9.29.0 + version: 9.29.0 + eslint-plugin-drizzle: + specifier: ^0.2.3 + version: 0.2.3 + eventid: + specifier: ^2.0.1 + version: 2.0.1 + exit-hook: + specifier: ^4.0.0 + version: 4.0.0 + fast-content-type-parse: + specifier: ^3.0.0 + version: 3.0.0 + fast-uri: + specifier: ^3.0.6 + version: 3.0.6 + fastmcp: + specifier: ^3.3.0 + version: 3.3.0 + get-port: + specifier: ^7.1.0 + version: 7.1.0 + hono: + specifier: ^4.7.11 + version: 4.7.11 + knip: + specifier: ^5.61.0 + version: 5.61.0 + ky: + specifier: 1.8.1 + version: 1.8.1 + lint-staged: + specifier: ^16.1.1 + version: 16.1.1 + ms: + specifier: ^2.1.3 + version: 2.1.3 + npm-run-all2: + specifier: ^8.0.4 + version: 8.0.4 + octokit: + specifier: ^5.0.3 + version: 5.0.3 + only-allow: + specifier: ^1.2.1 + version: 1.2.1 + open: + specifier: ^10.1.2 + version: 10.1.2 + openapi-typescript: + specifier: ^7.8.0 + version: 7.8.0 + ora: + specifier: ^8.2.0 + version: 8.2.0 + p-all: + specifier: ^5.0.0 + version: 5.0.0 + p-map: + specifier: ^7.0.3 + version: 7.0.3 + parse-json: + specifier: ^8.3.0 + version: 8.3.0 + plur: + specifier: ^5.1.0 + version: 5.1.0 + postgres: + specifier: ^3.4.7 + version: 3.4.7 + prettier: + specifier: ^3.5.3 + version: 3.5.3 react: specifier: ^19.1.0 version: 19.1.0 react-dom: specifier: ^19.1.0 version: 19.1.0 + react-email: + specifier: ^4.0.16 + version: 4.0.16 + resend: + specifier: ^4.6.0 + version: 4.6.0 + restore-cursor: + specifier: ^5.1.0 + version: 5.1.0 + semver: + specifier: ^7.7.2 + version: 7.7.2 + simple-git-hooks: + specifier: ^2.13.0 + version: 2.13.0 + sort-keys: + specifier: ^5.1.0 + version: 5.1.0 stripe: specifier: ^18.2.1 version: 18.2.1 + tsup: + specifier: ^8.5.0 + version: 8.5.0 + tsx: + specifier: ^4.20.3 + version: 4.20.3 + turbo: + specifier: ^2.5.4 + version: 2.5.4 type-fest: specifier: ^4.41.0 version: 4.41.0 - -overrides: - openauthjs: link:../../temp/openauth - '@agentic/openauth': link:../../temp/openauth/packages/openauth + typescript: + specifier: ^5.8.3 + version: 5.8.3 + unconfig: + specifier: ^7.3.2 + version: 7.3.2 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4 + vitest: + specifier: ^3.2.3 + version: 3.2.3 + wrangler: + specifier: ^4.20.0 + version: 4.20.0 + zod: + specifier: ^3.25.64 + version: 3.25.64 + zod-to-json-schema: + specifier: ^3.24.5 + version: 3.24.5 + zod-validation-error: + specifier: ^3.5.0 + version: 3.5.0 importers: .: - dependencies: - '@agentic/openauth': - specifier: link:../../temp/openauth/packages/openauth - version: link:../../temp/openauth/packages/openauth - openauthjs: - specifier: link:../../temp/openauth - version: link:../../temp/openauth devDependencies: '@dotenvx/dotenvx': specifier: ^1.44.2 @@ -100,9 +317,6 @@ importers: apps/api: dependencies: - '@agentic/openauth': - specifier: link:../../../../temp/openauth/packages/openauth - version: link:../../../../temp/openauth/packages/openauth '@agentic/platform': specifier: workspace:* version: link:../../packages/platform @@ -148,6 +362,9 @@ importers: hono: specifier: 'catalog:' version: 4.7.11 + ky: + specifier: 'catalog:' + version: 1.8.1 octokit: specifier: 'catalog:' version: 5.0.3 @@ -362,9 +579,6 @@ importers: specifier: 'catalog:' version: 4.41.0 devDependencies: - '@agentic/openauth': - specifier: link:../../../../temp/openauth/packages/openauth - version: link:../../../../temp/openauth/packages/openauth '@tailwindcss/postcss': specifier: ^4.1.10 version: 4.1.10 @@ -392,15 +606,18 @@ importers: packages/api-client: dependencies: - '@agentic/openauth': - specifier: link:../../../../temp/openauth/packages/openauth - version: link:../../../../temp/openauth/packages/openauth '@agentic/platform-core': specifier: workspace:* version: link:../core '@agentic/platform-types': specifier: workspace:* version: link:../types + '@standard-schema/spec': + specifier: ^1.0.0 + version: 1.0.0 + jose: + specifier: ^6.0.11 + version: 6.0.11 ky: specifier: 'catalog:' version: 1.8.1 @@ -4812,6 +5029,9 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + jose@6.0.11: + resolution: {integrity: sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -8846,14 +9066,13 @@ snapshots: '@types/pg-pool@2.0.6': dependencies: - '@types/pg': 8.6.1 + '@types/pg': 8.15.4 '@types/pg@8.15.4': dependencies: '@types/node': 24.0.1 pg-protocol: 1.10.0 pg-types: 2.2.0 - optional: true '@types/pg@8.6.1': dependencies: @@ -10752,6 +10971,8 @@ snapshots: jiti@2.4.2: {} + jose@6.0.11: {} + joycon@3.1.1: {} js-cookie@2.2.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 178a8458..2d4d0a2e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -55,7 +55,7 @@ catalog: hono: ^4.7.11 is-relative-url: ^4.0.0 knip: ^5.61.0 - ky: ^1.8.1 + ky: 1.8.1 lint-staged: ^16.1.1 ms: ^2.1.3 npm-run-all2: ^8.0.4