diff --git a/src/module.ts b/src/module.ts index 6027e85..90c9073 100644 --- a/src/module.ts +++ b/src/module.ts @@ -246,6 +246,11 @@ export default defineNuxtModule({ for (const provider of atprotoProviders) { // @ts-expect-error Not typesafe, but avoids repeating the same code for each provider runtimeConfig.oauth[provider] = defu(runtimeConfig.oauth[provider], atprotoProviderDefaultClientMetadata) as AtprotoProviderClientMetadata + addServerHandler({ + handler: resolver.resolve('./runtime/server/routes/atproto/client-metadata.json.get.ts'), + route: '/' + (runtimeConfig.oauth[provider] as AtprotoProviderClientMetadata).clientMetadataFilename, + method: 'get', + }) } // Keycloak OAuth diff --git a/src/runtime/server/lib/oauth/bluesky.ts b/src/runtime/server/lib/oauth/bluesky.ts index 3b0678c..b415f1c 100644 --- a/src/runtime/server/lib/oauth/bluesky.ts +++ b/src/runtime/server/lib/oauth/bluesky.ts @@ -1,5 +1,5 @@ import type { H3Event } from 'h3' -import { createError, eventHandler, getQuery, getRequestURL, sendRedirect } from 'h3' +import { createError, eventHandler, getQuery, sendRedirect } from 'h3' import type { Storage, StorageValue } from 'unstorage' import { NodeOAuthClient, OAuthCallbackError, OAuthResolverError, OAuthResponseError } from '@atproto/oauth-client-node' import type { @@ -7,14 +7,12 @@ import type { NodeSavedSessionStore, NodeSavedState, NodeSavedStateStore, - OAuthGrantType, } from '@atproto/oauth-client-node' import { Agent } from '@atproto/api' import type { AppBskyActorGetProfile } from '@atproto/api' -import { getOAuthRedirectURL } from '../utils' +import { getAtprotoClientMetadata } from '../../utils/atproto' import type { OAuthConfig } from '#auth-utils' -import { useRuntimeConfig, useStorage } from '#imports' -import type { AtprotoProviderClientMetadata } from '~/src/runtime/types/atproto' +import { useStorage } from '#imports' export interface OAuthBlueskyConfig { /** @@ -37,40 +35,8 @@ type BlueSkyTokens = NodeSavedSession['tokenSet'] export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: OAuthConfig) { return eventHandler(async (event: H3Event) => { - const blueskyRuntimeConfig = useRuntimeConfig(event).oauth.bluesky as AtprotoProviderClientMetadata - - const scopes = [...new Set(['atproto', ...config?.scope ?? [], ...blueskyRuntimeConfig.scope ?? []])] - const scope = scopes.join(' ') - - const grantTypes = [...new Set(['authorization_code', ...blueskyRuntimeConfig.grantTypes ?? []])] as [OAuthGrantType, ...OAuthGrantType[]] - - const requestURL = getRequestURL(event) - const baseUrl = `${requestURL.protocol}//${requestURL.host}` - - /** - * The redirect URL must be a valid URL, so we need to parse it to ensure it is correct. Will use the following order: - * 1. URL provided as part of the config of the event handler, on the condition that it was listed in the redirect URIs. - * 2. First URL provided in the runtime config. - * 3. The URL of the current request. - */ - const redirectURL = new URL( - (config?.redirectUrl && baseUrl + config.redirectUrl) - || (blueskyRuntimeConfig.redirectUris[0] && baseUrl + blueskyRuntimeConfig.redirectUris[0]) - || getOAuthRedirectURL(event), - ) - - const dev = import.meta.dev - if (dev && redirectURL.hostname === 'localhost') { - // For local development, Bluesky authorization servers allow "http://127.0.0.1" as a special value for redirect URIs - redirectURL.hostname = '127.0.0.1' - } - const redirectUris = (blueskyRuntimeConfig.redirectUris.length ? blueskyRuntimeConfig.redirectUris : [requestURL.pathname]) - .map(uri => new URL(`${redirectURL.protocol}//${redirectURL.host}${uri}`).toString()) as [string, ...string[]] - - const clientId = dev - // For local development, Bluesky authorization servers allow "http://localhost" as a special value for the client - ? `http://localhost?redirect_uri=${encodeURIComponent(redirectURL.toString())}&scope=${encodeURIComponent(scope)}` - : `${baseUrl}/${blueskyRuntimeConfig.clientMetadataFilename || 'bluesky/client-metadata.json'}` + const clientMetadata = getAtprotoClientMetadata(event, 'bluesky', config) + const scopes = clientMetadata.scope?.split(' ') ?? [] const storage = useStorage() const sessionStore = new SessionStore(storage) @@ -81,20 +47,7 @@ export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: O sessionStore, // Todo: This needs to be exposed publicly so that the authorization server can validate the client // It is not verified by Bluesky yet, but it might be in the future - clientMetadata: { - client_name: blueskyRuntimeConfig.clientName || undefined, - client_uri: blueskyRuntimeConfig.clientUri || undefined, - logo_uri: blueskyRuntimeConfig.logoUri || undefined, - policy_uri: blueskyRuntimeConfig.policyUri || undefined, - tos_uri: blueskyRuntimeConfig.tosUri || undefined, - client_id: clientId, - redirect_uris: redirectUris, - scope, - grant_types: grantTypes, - application_type: blueskyRuntimeConfig.applicationType, - token_endpoint_auth_method: blueskyRuntimeConfig.tokenEndpointAuthMethod, - dpop_bound_access_tokens: true, - }, + clientMetadata: clientMetadata, }) const query = getQuery(event) @@ -107,7 +60,7 @@ export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: O message: 'Query parameter `handle` empty or missing. Please provide a valid Bluesky handle.', }) - const url = await client.authorize(handle, { scope }) + const url = await client.authorize(handle, { scope: clientMetadata.scope }) return sendRedirect(event, url.toString()) } catch (err) { diff --git a/src/runtime/server/routes/atproto/client-metadata.json.get.ts b/src/runtime/server/routes/atproto/client-metadata.json.get.ts new file mode 100644 index 0000000..09cdc27 --- /dev/null +++ b/src/runtime/server/routes/atproto/client-metadata.json.get.ts @@ -0,0 +1,23 @@ +import { defineEventHandler, createError } from 'h3' +import { getAtprotoClientMetadata } from '../../utils/atproto' +import { useRuntimeConfig } from '#imports' +import { atprotoProviders } from '~/src/utils/atproto' +import type { AtprotoProviderClientMetadata } from '~/src/runtime/types/atproto' + +export default defineEventHandler((event) => { + const path = event.path.slice(1) + const runtimeConfig = useRuntimeConfig(event) + + for (const provider of atprotoProviders) { + const config: AtprotoProviderClientMetadata = runtimeConfig.oauth[provider] + + if (config.clientMetadataFilename === path) { + return getAtprotoClientMetadata(event, provider) + } + } + + throw createError({ + statusCode: 404, + message: 'Provider not found', + }) +}) diff --git a/src/runtime/server/utils/atproto.ts b/src/runtime/server/utils/atproto.ts new file mode 100644 index 0000000..d8ee921 --- /dev/null +++ b/src/runtime/server/utils/atproto.ts @@ -0,0 +1,64 @@ +import type { H3Event } from 'h3' +import type { OAuthClientMetadataInput, OAuthGrantType } from '@atproto/oauth-client-node' +import type { AtprotoProviderClientMetadata } from '../../types/atproto' +import type { OAuthBlueskyConfig } from '../lib/oauth/bluesky' +import { getOAuthRedirectURL } from '../lib/utils' +import type { OAuthConfig, OAuthProvider } from '#auth-utils' +import { getRequestURL, useRuntimeConfig } from '#imports' + +export function getAtprotoClientMetadata( + event: H3Event, + provider: OAuthProvider, + config?: OAuthConfig['config'], +): OAuthClientMetadataInput { + const providerRuntimeConfig: AtprotoProviderClientMetadata = useRuntimeConfig(event).oauth[provider] as AtprotoProviderClientMetadata + const scopes = [...new Set(['atproto', ...config?.scope ?? [], ...providerRuntimeConfig.scope ?? []])] + const scope = scopes.join(' ') + + const grantTypes = [...new Set(['authorization_code', ...providerRuntimeConfig.grantTypes ?? []])] as [OAuthGrantType, ...OAuthGrantType[]] + + const requestURL = getRequestURL(event) + const baseUrl = `${requestURL.protocol}//${requestURL.host}` + + /** + * The redirect URL must be a valid URL, so we need to parse it to ensure it is correct. Will use the following order: + * 1. URL provided as part of the config of the event handler, on the condition that it was listed in the redirect URIs. + * 2. First URL provided in the runtime config. + * 3. The URL of the current request. + */ + const redirectURL = new URL( + (config?.redirectUrl && baseUrl + config.redirectUrl) + || (providerRuntimeConfig.redirectUris[0] && baseUrl + providerRuntimeConfig.redirectUris[0]) + || getOAuthRedirectURL(event), + ) + + const dev = import.meta.dev + if (dev && redirectURL.hostname === 'localhost') { + // For local development, Bluesky authorization servers allow "http://127.0.0.1" as a special value for redirect URIs + redirectURL.hostname = '127.0.0.1' + } + const redirectUris = (providerRuntimeConfig.redirectUris.length ? providerRuntimeConfig.redirectUris : [requestURL.pathname]) + .map(uri => new URL(`${redirectURL.protocol}//${redirectURL.host}${uri}`).toString()) as [string, ...string[]] + + const clientId = dev + // For local development, Bluesky authorization servers allow "http://localhost" as a special value for the client + ? `http://localhost?redirect_uri=${encodeURIComponent(redirectURL.toString())}&scope=${encodeURIComponent(scope)}` + : `${baseUrl}/${providerRuntimeConfig.clientMetadataFilename || provider + '/client-metadata.json'}` + + const clientMetadata: OAuthClientMetadataInput = { + client_name: providerRuntimeConfig.clientName || undefined, + client_uri: providerRuntimeConfig.clientUri || undefined, + logo_uri: providerRuntimeConfig.logoUri || undefined, + policy_uri: providerRuntimeConfig.policyUri || undefined, + tos_uri: providerRuntimeConfig.tosUri || undefined, + client_id: clientId, + redirect_uris: redirectUris, + scope, + grant_types: grantTypes, + application_type: providerRuntimeConfig.applicationType, + token_endpoint_auth_method: providerRuntimeConfig.tokenEndpointAuthMethod, + dpop_bound_access_tokens: true, + } + + return clientMetadata +}