Skip to content
Merged
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
4 changes: 1 addition & 3 deletions app/composables/useAtproto.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import type { UserSession } from '#shared/schemas/userSession'

export function useAtproto() {
const {
data: user,
pending,
clear,
} = useFetch<UserSession | null>('/api/auth/session', {
} = useFetch('/api/auth/session', {
server: false,
immediate: !import.meta.test,
})
Expand Down
10 changes: 0 additions & 10 deletions modules/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,6 @@ export default defineNuxtModule({
...nitroConfig.storage[FETCH_CACHE_STORAGE_BASE],
driver: 'vercel-runtime-cache',
}

const env = process.env.VERCEL_ENV

nitroConfig.storage['oauth-atproto-state'] = {
driver: env === 'production' ? 'vercel-kv' : 'vercel-runtime-cache',
}

nitroConfig.storage['oauth-atproto-session'] = {
driver: env === 'production' ? 'vercel-kv' : 'vercel-runtime-cache',
}
})
},
})
8 changes: 0 additions & 8 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,14 +151,6 @@ export default defineNuxtConfig({
driver: 'fsLite',
base: './.cache/fetch',
},
'oauth-atproto-state': {
driver: 'fsLite',
base: './.cache/atproto-oauth/state',
},
'oauth-atproto-session': {
driver: 'fsLite',
base: './.cache/atproto-oauth/session',
},
},
typescript: {
tsConfig: {
Expand Down
19 changes: 10 additions & 9 deletions server/api/auth/atproto.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { NodeOAuthClient } from '@atproto/oauth-client-node'
import { createError, getQuery, sendRedirect } from 'h3'
import { useOAuthStorage } from '#server/utils/atproto/storage'
import { SLINGSHOT_HOST } from '#shared/utils/constants'
import type { UserSession } from '#shared/schemas/userSession'
import { useServerSession } from '#server/utils/server-session'
import type { PublicUserSession } from '#shared/schemas/publicUserSession'

export default defineEventHandler(async event => {
const config = useRuntimeConfig(event)
Expand All @@ -16,7 +17,8 @@ export default defineEventHandler(async event => {

const query = getQuery(event)
const clientMetadata = getOauthClientMetadata()
const { stateStore, sessionStore } = useOAuthStorage(event)
const session = await useServerSession(event)
const { stateStore, sessionStore } = useOAuthStorage(session)

const atclient = new NodeOAuthClient({
stateStore,
Expand Down Expand Up @@ -48,17 +50,16 @@ export default defineEventHandler(async event => {
const agent = new Agent(authSession)
event.context.agent = agent

const session = await useSession(event, {
password: config.sessionPassword,
})

const response = await fetch(
`https://${SLINGSHOT_HOST}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${agent.did}`,
{ headers: { 'User-Agent': 'npmx' } },
)
const miniDoc = (await response.json()) as UserSession

await session.update(miniDoc)
if (response.ok) {
const miniDoc: PublicUserSession = await response.json()
await session.update({
public: miniDoc,
})
}

return sendRedirect(event, '/')
})
6 changes: 4 additions & 2 deletions server/api/auth/session.delete.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => {
await Promise.all([oAuthSession?.signOut(), serverSession.clear()])

// Even tho the signOut also clears part of the server cache should be done in order
// to let the oAuth package do any other clean up it may need
await oAuthSession?.signOut()
await serverSession.clear()
Comment on lines +2 to +5
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Ensure server session is cleared even when sign‑out fails.

If signOut() throws, serverSession.clear() will never run, leaving stale session state.

Proposed fix
-  await oAuthSession?.signOut()
-  await serverSession.clear()
+  try {
+    await oAuthSession?.signOut()
+  } finally {
+    await serverSession.clear()
+  }

return 'Session cleared'
})
4 changes: 2 additions & 2 deletions server/api/auth/session.get.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { UserSessionSchema } from '#shared/schemas/userSession'
import { PublicUserSessionSchema } from '#shared/schemas/publicUserSession'
import { safeParse } from 'valibot'

export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => {
const result = safeParse(UserSessionSchema, serverSession.data)
const result = safeParse(PublicUserSessionSchema, serverSession.data.public)
Comment on lines +1 to +5
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard against missing session data before validation.

serverSession.data can be undefined; accessing .public will throw and bypass the intended validation.

Proposed fix
-  const result = safeParse(PublicUserSessionSchema, serverSession.data.public)
+  const result = safeParse(PublicUserSessionSchema, serverSession.data?.public)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { PublicUserSessionSchema } from '#shared/schemas/publicUserSession'
import { safeParse } from 'valibot'
export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => {
const result = safeParse(UserSessionSchema, serverSession.data)
const result = safeParse(PublicUserSessionSchema, serverSession.data.public)
import { PublicUserSessionSchema } from '#shared/schemas/publicUserSession'
import { safeParse } from 'valibot'
export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => {
const result = safeParse(PublicUserSessionSchema, serverSession.data?.public)

if (!result.success) {
return null
}
Expand Down
42 changes: 16 additions & 26 deletions server/utils/atproto/oauth-session-store.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,30 @@
import type { NodeSavedSession, NodeSavedSessionStore } from '@atproto/oauth-client-node'
import type { H3Event } from 'h3'

/**
* Storage key prefix for oauth session storage.
*/
export const OAUTH_SESSION_CACHE_STORAGE_BASE = 'oauth-atproto-session'
import type { UserServerSession } from '#shared/types/userSession'
import type { SessionManager } from 'h3'

export class OAuthSessionStore implements NodeSavedSessionStore {
// TODO: not sure if we will support multi accounts, but if we do in the future will need to change this around
private readonly cookieKey = 'oauth:atproto:session'
private readonly storage = useStorage(OAUTH_SESSION_CACHE_STORAGE_BASE)
private readonly session: SessionManager<UserServerSession>

constructor(private event: H3Event) {}
constructor(session: SessionManager<UserServerSession>) {
this.session = session
}

async get(): Promise<NodeSavedSession | undefined> {
const sessionKey = getCookie(this.event, this.cookieKey)
if (!sessionKey) return
const result = await this.storage.getItem<NodeSavedSession>(sessionKey)
if (!result) return
return result
const sessionData = this.session.data
if (!sessionData) return undefined
return sessionData.oauthSession
}

async set(key: string, val: NodeSavedSession) {
setCookie(this.event, this.cookieKey, key, {
httpOnly: true,
secure: !import.meta.dev,
sameSite: 'lax',
async set(_key: string, val: NodeSavedSession) {
// We are ignoring the key since the mapping is already done in the session
await this.session.update({
oauthSession: val,
})
await this.storage.setItem<NodeSavedSession>(key, val)
}

async del() {
const sessionKey = getCookie(this.event, this.cookieKey)
if (sessionKey) {
await this.storage.del(sessionKey)
}
deleteCookie(this.event, this.cookieKey)
await this.session.update({
oauthSession: undefined,
})
}
}
41 changes: 16 additions & 25 deletions server/utils/atproto/oauth-state-store.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,30 @@
import type { NodeSavedState, NodeSavedStateStore } from '@atproto/oauth-client-node'
import type { H3Event } from 'h3'

/**
* Storage key prefix for oauth state storage.
*/
export const OAUTH_STATE_CACHE_STORAGE_BASE = 'oauth-atproto-state'
import type { UserServerSession } from '#shared/types/userSession'
import type { SessionManager } from 'h3'

export class OAuthStateStore implements NodeSavedStateStore {
private readonly cookieKey = 'oauth:atproto:state'
private readonly storage = useStorage(OAUTH_STATE_CACHE_STORAGE_BASE)
private readonly session: SessionManager<UserServerSession>

constructor(private event: H3Event) {}
constructor(session: SessionManager<UserServerSession>) {
this.session = session
}

async get(): Promise<NodeSavedState | undefined> {
const stateKey = getCookie(this.event, this.cookieKey)
if (!stateKey) return
const result = await this.storage.getItem<NodeSavedState>(stateKey)
if (!result) return
return result
const sessionData = this.session.data
if (!sessionData) return undefined
return sessionData.oauthState
}

async set(key: string, val: NodeSavedState) {
setCookie(this.event, this.cookieKey, key, {
httpOnly: true,
secure: !import.meta.dev,
sameSite: 'lax',
async set(_key: string, val: NodeSavedState) {
// We are ignoring the key since the mapping is already done in the session
await this.session.update({
oauthState: val,
})
await this.storage.setItem<NodeSavedState>(key, val)
}

async del() {
const stateKey = getCookie(this.event, this.cookieKey)
deleteCookie(this.event, this.cookieKey)
if (stateKey) {
await this.storage.del(stateKey)
}
await this.session.update({
oauthState: undefined,
})
}
}
18 changes: 4 additions & 14 deletions server/utils/atproto/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { NodeOAuthClient } from '@atproto/oauth-client-node'
import { parse } from 'valibot'
import { getOAuthLock } from '#server/utils/atproto/lock'
import { useOAuthStorage } from '#server/utils/atproto/storage'
import { UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constants'
import { OAuthMetadataSchema } from '#shared/schemas/oauth'
// @ts-expect-error virtual file from oauth module
import { clientUri } from '#oauth/config'
import { useServerSession } from '#server/utils/server-session'
// TODO: limit scope as features gets added. atproto just allows login so no scary login screen till we have scopes
export const scope = 'atproto'

Expand Down Expand Up @@ -44,7 +44,8 @@ type EventHandlerWithOAuthSession<T extends EventHandlerRequest, D> = (

async function getOAuthSession(event: H3Event): Promise<OAuthSession | undefined> {
const clientMetadata = getOauthClientMetadata()
const { stateStore, sessionStore } = useOAuthStorage(event)
const serverSession = await useServerSession(event)
const { stateStore, sessionStore } = useOAuthStorage(serverSession)

const client = new NodeOAuthClient({
stateStore,
Expand All @@ -64,18 +65,7 @@ export function eventHandlerWithOAuthSession<T extends EventHandlerRequest, D>(
handler: EventHandlerWithOAuthSession<T, D>,
) {
return defineEventHandler(async event => {
const config = useRuntimeConfig(event)

if (!config.sessionPassword) {
throw createError({
status: 500,
message: UNSET_NUXT_SESSION_PASSWORD,
})
}

const serverSession = await useSession(event, {
password: config.sessionPassword,
})
const serverSession = await useServerSession(event)

const oAuthSession = await getOAuthSession(event)
return await handler(event, oAuthSession, serverSession)
Expand Down
9 changes: 5 additions & 4 deletions server/utils/atproto/storage.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { H3Event } from 'h3'
import type { SessionManager } from 'h3'
import { OAuthStateStore } from './oauth-state-store'
import { OAuthSessionStore } from './oauth-session-store'
import type { UserServerSession } from '#shared/types/userSession'

export const useOAuthStorage = (event: H3Event) => {
export const useOAuthStorage = (session: SessionManager<UserServerSession>) => {
return {
stateStore: new OAuthStateStore(event),
sessionStore: new OAuthSessionStore(event),
stateStore: new OAuthStateStore(session),
sessionStore: new OAuthSessionStore(session),
}
}
22 changes: 22 additions & 0 deletions server/utils/server-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// This is for getting the session on the npmx server and differs from the OAuthSession
import type { H3Event } from 'h3'
import type { UserServerSession } from '#shared/types/userSession'

/**
* Get's the user's session that is stored on the server
* @param event
* @returns
*/
export const useServerSession = async (event: H3Event) => {
const config = useRuntimeConfig(event)

if (!config.sessionPassword) {
throw new Error('Session password is not configured')
}

const serverSession = useSession<UserServerSession>(event, {
password: config.sessionPassword,
})

return serverSession
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { object, string, pipe, url } from 'valibot'
import type { InferOutput } from 'valibot'

export const UserSessionSchema = object({
export const PublicUserSessionSchema = object({
// Safe to pass to the frontend
did: string(),
handle: string(),
pds: pipe(string(), url()),
})

export type UserSession = InferOutput<typeof UserSessionSchema>
export type PublicUserSession = InferOutput<typeof PublicUserSessionSchema>
14 changes: 14 additions & 0 deletions shared/types/userSession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { NodeSavedSession, NodeSavedState } from '@atproto/oauth-client-node'

export interface UserServerSession {
public: {
did: string
handle: string
pds: string
}
// Only to be used in the atproto session and state stores
// Will need to change to Record<string, T> and add a current logged in user if we ever want to support
// multiple did logins per server session
oauthSession: NodeSavedSession | undefined
oauthState: NodeSavedState | undefined
}
Loading