Skip to content

Commit

Permalink
feat: serve client metadata dynamically
Browse files Browse the repository at this point in the history
  • Loading branch information
noook committed Nov 18, 2024
1 parent 7941637 commit 97f7d26
Show file tree
Hide file tree
Showing 4 changed files with 99 additions and 54 deletions.
5 changes: 5 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,11 @@ export default defineNuxtModule<ModuleOptions>({
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
Expand Down
61 changes: 7 additions & 54 deletions src/runtime/server/lib/oauth/bluesky.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
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 {
NodeSavedSession,
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 {
/**
Expand All @@ -37,40 +35,8 @@ type BlueSkyTokens = NodeSavedSession['tokenSet']

export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: OAuthConfig<OAuthBlueskyConfig, BlueSkyUser, BlueSkyTokens>) {
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)
Expand All @@ -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)
Expand All @@ -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) {
Expand Down
23 changes: 23 additions & 0 deletions src/runtime/server/routes/atproto/client-metadata.json.get.ts
Original file line number Diff line number Diff line change
@@ -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',
})
})
64 changes: 64 additions & 0 deletions src/runtime/server/utils/atproto.ts
Original file line number Diff line number Diff line change
@@ -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<OAuthBlueskyConfig>['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
}

0 comments on commit 97f7d26

Please sign in to comment.