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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions modules/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@
async setup() {
const nuxt = useNuxt()

const { previewUrl, productionUrl } = await getEnv(nuxt.options.dev)
const clientUri = productionUrl || previewUrl || 'http://127.0.0.1:3000'
const { previewUrl, productionUrl, env } = await getEnv(nuxt.options.dev)
const clientUri =
productionUrl ||
(env === 'canary' ? 'https://main.npmx.dev' : previewUrl) ||
'http://127.0.0.1:3000'

// bake it into a virtual file
addServerTemplate({
Expand All @@ -21,7 +24,7 @@
getContents: () => `export const clientUri = ${JSON.stringify(clientUri)};`,
})

if (nuxt.options._prepare || process.env.NUXT_SESSION_PASSWORD) {

Check warning on line 27 in modules/oauth.ts

View workflow job for this annotation

GitHub Actions / 🤖 Autofix code

eslint(no-underscore-dangle)

Unexpected dangling '_' in '`_prepare`'.
return
}

Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,14 @@
},
"dependencies": {
"@atcute/bluesky-richtext-segmenter": "3.0.1",
"@atcute/identity-resolver": "2.0.1",
"@atcute/identity-resolver-node": "2.0.1",
"@atcute/lexicons": "2.0.2",
"@atcute/oauth-node-client": "2.0.1",
"@atcute/tid": "1.1.3",
"@atproto/api": "0.20.25",
"@atproto/lex": "0.1.7",
"@atproto/lex-password-session": "0.1.4",
"@atproto/oauth-client-node": "0.3.15",
"@deno/doc": "jsr:^0.189.1",
"@floating-ui/vue": "2.0.0",
"@iconify-json/lucide": "1.2.101",
Expand Down
1,259 changes: 965 additions & 294 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions scripts/gen-jwk.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { JoseKey } from '@atproto/oauth-client-node'
import { generateClientAssertionKey } from '@atcute/oauth-node-client'

async function run() {
const kid = Date.now().toString()
const key = await JoseKey.generate(['ES256'], kid)
const jwk = key.privateJwk
const jwk = await generateClientAssertionKey(kid, 'ES256')

console.log(JSON.stringify(jwk))
}
Expand Down
40 changes: 25 additions & 15 deletions server/api/auth/atproto.get.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { OAuthSession } from '@atproto/oauth-client-node'
import { OAuthCallbackError } from '@atproto/oauth-client-node'
import type { AuthorizeTarget, OAuthSession } from '@atcute/oauth-node-client'
import { OAuthCallbackError } from '@atcute/oauth-node-client'
import { createError, getQuery, sendRedirect, setCookie, getCookie, deleteCookie } from 'h3'
import type { H3Event } from 'h3'
import { SLINGSHOT_HOST } from '#shared/utils/constants'
Expand All @@ -10,14 +10,23 @@ import { Client, isAtUriString, isTypedBlobRef } from '@atproto/lex'
import * as app from '#shared/types/lexicons/app'
import * as blue from '#shared/types/lexicons/blue'
import { isAtIdentifierString } from '@atproto/lex'
import { scope } from '#server/utils/atproto/oauth'
import { sessionAsAgent } from '#server/utils/atproto/oauth'
import { UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constants'
// @ts-expect-error virtual file from oauth module
import { clientUri } from '#oauth/config'
import type { ActorIdentifier } from '@atcute/lexicons'
import { isActorIdentifier } from '@atcute/lexicons/syntax'

const OAUTH_REQUEST_COOKIE_PREFIX = 'atproto_oauth_req'
const slingshotClient = new Client({ service: `https://${SLINGSHOT_HOST}` })

function isValidTarget(target: unknown): target is `https://${string}` | ActorIdentifier {
return (
typeof target === 'string' &&
(isAtIdentifierString(target) || (URL.canParse(target) && target.startsWith('https://')))
)
}

export default defineEventHandler(async event => {
const config = useRuntimeConfig(event)
if (!config.sessionPassword) {
Expand All @@ -32,10 +41,7 @@ export default defineEventHandler(async event => {

if (query.handle) {
// Initiate auth flow
if (
typeof query.handle !== 'string' ||
(!query.handle.startsWith('https://') && !isAtIdentifierString(query.handle))
) {
if (!isValidTarget(query.handle)) {
throw createError({
statusCode: 400,
message: 'Invalid handle parameter',
Expand All @@ -55,9 +61,13 @@ export default defineEventHandler(async event => {
// Invalid URL, fall back to root
}

const target: AuthorizeTarget = isActorIdentifier(query.handle)
? { type: 'account', identifier: query.handle }
: { type: 'pds', serviceUrl: query.handle }

try {
const redirectUrl = await event.context.oauthClient.authorize(query.handle, {
scope,
const { url } = await event.context.oauthClient.authorize({
target,
prompt: query.create ? 'create' : undefined,
// TODO: I do not believe this is working as expected on
// an unsupported locale on the PDS. Gives Invalid at body.ui_locales
Expand All @@ -66,7 +76,7 @@ export default defineEventHandler(async event => {
state: encodeOAuthState(event, { redirectPath }),
})

return sendRedirect(event, redirectUrl.toString())
return sendRedirect(event, url.toString())
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to initiate authentication.'

Expand Down Expand Up @@ -182,8 +192,8 @@ function generateRandomHexString(byteLength: number = 16): string {
* @returns The original OAuth state if the id is valid
* @throws An error if the id is missing or invalid, indicating a potential issue with cookies or expired state
*/
function decodeOAuthState(event: H3Event, state: string | null): OAuthStateData {
if (!state) {
function decodeOAuthState(event: H3Event, state: unknown): OAuthStateData {
if (!state || typeof state !== 'string') {
// May happen during transition period (if a user initiated auth flow before
// the release with the new state handling, then tries to complete it after
// the release).
Expand Down Expand Up @@ -233,7 +243,7 @@ async function getMiniProfile(authSession: OAuthSession) {
if (response.success) {
const miniDoc = response.body

let avatar: string | undefined = await getAvatar(authSession.did, miniDoc.pds)
const avatar: string | undefined = await getAvatar(authSession.did, miniDoc.pds)

return {
...miniDoc,
Expand All @@ -242,7 +252,7 @@ async function getMiniProfile(authSession: OAuthSession) {
} else {
//If slingshot fails we still want to set some key info we need.
const pdsBase = (await authSession.getTokenInfo()).aud
let avatar: string | undefined = await getAvatar(authSession.did, pdsBase)
const avatar: string | undefined = await getAvatar(authSession.did, pdsBase)
return {
did: authSession.did,
handle: 'Not available',
Expand Down Expand Up @@ -285,7 +295,7 @@ async function getAvatar(did: DidString, pds: string) {
}

async function getNpmxProfile(handle: string, authSession: OAuthSession) {
const client = new Client(authSession)
const client = new Client(sessionAsAgent(authSession))

// get existing npmx profile OR create a new one
const profileUri = `at://${client.did}/dev.npmx.actor.profile/self`
Expand Down
7 changes: 4 additions & 3 deletions server/api/social/like.delete.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as v from 'valibot'
import { Client } from '@atproto/lex'
import { sessionAsAgent } from '#server/utils/atproto/oauth'
import * as dev from '#shared/types/lexicons/dev'
import { PackageLikeBodySchema } from '#shared/schemas/social'
import { throwOnMissingOAuthScope } from '#server/utils/atproto/oauth'
Expand All @@ -11,8 +12,8 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}

//Checks if the user has a scope to like packages
await throwOnMissingOAuthScope(oAuthSession, LIKES_SCOPE)
// Checks if the user has a scope to like packages
await throwOnMissingOAuthScope(oAuthSession, dev.npmx.feed.like.$nsid)

const body = v.parse(PackageLikeBodySchema, await readBody(event))

Expand All @@ -24,7 +25,7 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
)

if (getTheUsersLikedRecord) {
const client = new Client(oAuthSession)
const client = new Client(sessionAsAgent(oAuthSession))

await client.delete(dev.npmx.feed.like, {
rkey: getTheUsersLikedRecord.rkey,
Expand Down
8 changes: 4 additions & 4 deletions server/api/social/like.post.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as v from 'valibot'
import { Client, toDatetimeString } from '@atproto/lex'
import { sessionAsAgent } from '#server/utils/atproto/oauth'
import * as dev from '#shared/types/lexicons/dev'
import type { UriString } from '@atproto/lex'
import { LIKES_SCOPE } from '#shared/utils/constants'
import { PackageLikeBodySchema } from '#shared/schemas/social'
import { throwOnMissingOAuthScope } from '#server/utils/atproto/oauth'

Expand All @@ -13,8 +13,8 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}

//Checks if the user has a scope to like packages
await throwOnMissingOAuthScope(oAuthSession, LIKES_SCOPE)
// Checks if the user has a scope to like packages
await throwOnMissingOAuthScope(oAuthSession, dev.npmx.feed.like.$nsid)

const body = v.parse(PackageLikeBodySchema, await readBody(event))

Expand All @@ -27,7 +27,7 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
}

const subjectRef = PACKAGE_SUBJECT_REF(body.packageName)
const client = new Client(oAuthSession)
const client = new Client(sessionAsAgent(oAuthSession))

const like = dev.npmx.feed.like.$build({
createdAt: toDatetimeString(new Date()),
Expand Down
5 changes: 3 additions & 2 deletions server/api/social/profile/[identifier]/index.put.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { parse } from 'valibot'
import { Client } from '@atproto/lex'
import { sessionAsAgent } from '#server/utils/atproto/oauth'
import * as dev from '#shared/types/lexicons/dev'
import type { NPMXProfile } from '#shared/types/social'
import { ProfileEditBodySchema } from '#shared/schemas/social'
Expand All @@ -11,15 +12,15 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}

await throwOnMissingOAuthScope(oAuthSession, PROFILE_SCOPE)
await throwOnMissingOAuthScope(oAuthSession, dev.npmx.actor.profile.$nsid)

const requestBody = await readBody(event)
if (requestBody.website && !URL.canParse(requestBody.website)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid website' })
}

const body = parse(ProfileEditBodySchema, requestBody)
const client = new Client(oAuthSession)
const client = new Client(sessionAsAgent(oAuthSession))

const profile = dev.npmx.actor.profile.$build({
displayName: body.displayName,
Expand Down
6 changes: 3 additions & 3 deletions server/plugins/oauth-client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { NodeOAuthClient } from '@atproto/oauth-client-node'
import type { OAuthClient } from '@atcute/oauth-node-client'

/**
* Creates a long living instance of the NodeOAuthClient.
* Creates a long living instance of the OAuthClient.
*/
export default defineNitroPlugin(async nitroApp => {
const oauthClient = await getNodeOAuthClient()
Expand All @@ -15,6 +15,6 @@ export default defineNitroPlugin(async nitroApp => {
// Extend the H3EventContext type
declare module 'h3' {
interface H3EventContext {
oauthClient: NodeOAuthClient
oauthClient: OAuthClient
}
}
15 changes: 6 additions & 9 deletions server/routes/.well-known/jwks.json.get.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { loadJWKs } from '#server/utils/atproto/oauth'

export default defineEventHandler(async _ => {
const keys = await loadJWKs()
if (!keys) {
console.error('Failed to load JWKs. May not be set')
return []
export default defineEventHandler(event => {
const jwks = event.context.oauthClient.jwks
if (!jwks) {
console.error('JWKS not configured (running as public client)')
return { keys: [] }
}

return keys.publicJwks
return jwks
})
6 changes: 2 additions & 4 deletions server/routes/oauth-client-metadata.json.get.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
export default defineEventHandler(async _ => {
const keyset = await loadJWKs()
const pk = keyset?.findPrivateKey({ usage: 'sign' })
return getOauthClientMetadata(pk?.alg)
export default defineEventHandler(event => {
return event.context.oauthClient.metadata
})
17 changes: 7 additions & 10 deletions server/utils/atproto/lock.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import { setTimeout } from 'node:timers/promises'
import type { RuntimeLock } from '@atproto/oauth-client-node'
import { requestLocalLock } from '@atproto/oauth-client-node'
import type { LockFunction } from '@atcute/oauth-node-client'
import { Redis } from '@upstash/redis'

type Awaitable<T> = T | PromiseLike<T>

/**
* Creates a distributed lock using Upstash Redis.
* Falls back gracefully if the lock cannot be acquired.
*/
function createUpstashLock(redis: Redis): RuntimeLock {
return async <T>(key: string, fn: () => Awaitable<T>): Promise<T> => {
function createUpstashLock(redis: Redis): LockFunction {
return async <T>(key: string, fn: () => Promise<T>): Promise<T> => {
const lockKey = `oauth:lock:${key}`
const lockValue = crypto.randomUUID()
const lockTTL = 30 // seconds
Expand Down Expand Up @@ -50,9 +47,9 @@ function createUpstashLock(redis: Redis): RuntimeLock {
/**
* Returns the appropriate lock mechanism based on environment:
* - Production with Upstash config: distributed Redis lock
* - Otherwise: in-memory lock (sufficient for single instance)
* - Otherwise: undefined (OAuthClient uses its own in-memory lock by default)
*/
export function getOAuthLock(): RuntimeLock {
export function getOAuthLock(): LockFunction | undefined {
const config = useRuntimeConfig()

// Use distributed lock in production if Upstash is configured
Expand All @@ -64,6 +61,6 @@ export function getOAuthLock(): RuntimeLock {
return createUpstashLock(redis)
}

// Fall back to in-memory lock for dev/preview or when Redis isn't configured
return requestLocalLock
// Fall back to undefined for dev/preview — OAuthClient defaults to in-memory lock
return undefined
}
18 changes: 11 additions & 7 deletions server/utils/atproto/oauth-session-store.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { NodeSavedSession, NodeSavedSessionStore } from '@atproto/oauth-client-node'
import type { SessionStore, StoredSession } from '@atcute/oauth-node-client'
import { OAUTH_CACHE_STORAGE_BASE } from '#server/utils/atproto/storage'

// Refresh tokens from a confidential client should last for 180 days, each new refresh of access token resets
// the expiration with the new refresh token. Shorting to 179 days to keep it a bit simpler since we rely on redis to clear sessions
// Note: This expiration only lasts this long in production. Local dev is 2 weeks
const SESSION_EXPIRATION = CACHE_MAX_AGE_ONE_DAY * 179

export class OAuthSessionStore implements NodeSavedSessionStore {
export class OAuthSessionStore implements SessionStore {
private readonly cache: CacheAdapter

constructor() {
Expand All @@ -17,16 +17,20 @@ export class OAuthSessionStore implements NodeSavedSessionStore {
return `sessions:${did}`
}

async get(key: string): Promise<NodeSavedSession | undefined> {
let session = await this.cache.get<NodeSavedSession>(this.createStorageKey(key))
async get(key: string): Promise<StoredSession | undefined> {
const session = await this.cache.get<StoredSession>(this.createStorageKey(key))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the types the same here from @atproto to @ATCUTE? Do we want to do some kind of translate layer or just have everyone login again?

return session ?? undefined
}

async set(key: string, val: NodeSavedSession) {
await this.cache.set<NodeSavedSession>(this.createStorageKey(key), val, SESSION_EXPIRATION)
async set(key: string, val: StoredSession) {
await this.cache.set<StoredSession>(this.createStorageKey(key), val, SESSION_EXPIRATION)
}

async del(key: string) {
async delete(key: string) {
await this.cache.delete(this.createStorageKey(key))
}

async clear() {
// Cache adapter does not expose bulk-clear; individual sessions expire via TTL
}
}
18 changes: 11 additions & 7 deletions server/utils/atproto/oauth-state-store.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { NodeSavedState, NodeSavedStateStore } from '@atproto/oauth-client-node'
import type { StateStore, StoredState } from '@atcute/oauth-node-client'
import { OAUTH_CACHE_STORAGE_BASE } from './storage'

// It is recommended that oauth state is only saved for 30 minutes
const STATE_EXPIRATION = CACHE_MAX_AGE_ONE_MINUTE * 30

export class OAuthStateStore implements NodeSavedStateStore {
export class OAuthStateStore implements StateStore {
private readonly cache: CacheAdapter

constructor() {
Expand All @@ -15,16 +15,20 @@ export class OAuthStateStore implements NodeSavedStateStore {
return `state:${key}`
}

async get(key: string): Promise<NodeSavedState | undefined> {
const state = await this.cache.get<NodeSavedState>(this.createStorageKey(key))
async get(key: string): Promise<StoredState | undefined> {
const state = await this.cache.get<StoredState>(this.createStorageKey(key))
return state ?? undefined
}

async set(key: string, val: NodeSavedState) {
await this.cache.set<NodeSavedState>(this.createStorageKey(key), val, STATE_EXPIRATION)
async set(key: string, val: StoredState) {
await this.cache.set<StoredState>(this.createStorageKey(key), val, STATE_EXPIRATION)
}

async del(key: string) {
async delete(key: string) {
await this.cache.delete(this.createStorageKey(key))
}

async clear() {
// Cache adapter does not expose bulk-clear; individual states expire via TTL
}
}
Loading
Loading