[READ-ONLY] a fast, modern browser for the npm registry

fix: small oauth fixes to extend sessions (hopefully) (#905)

authored by baileytownsend.dev and committed by

GitHub c8fcc6ee 217e0724

+347 -60
+9 -1
lexicons.json
··· 1 1 { 2 2 "version": 1, 3 - "lexicons": ["site.standard.document"], 3 + "lexicons": ["app.bsky.actor.profile", "site.standard.document"], 4 4 "resolutions": { 5 + "app.bsky.actor.profile": { 6 + "uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.actor.profile", 7 + "cid": "bafyreia6umzg3a6d7mjbow4p57tviey45muohklhgsvjoamcctoiusr4pe" 8 + }, 9 + "com.atproto.label.defs": { 10 + "uri": "at://did:plc:6msi3pj7krzih5qxqtryxlzw/com.atproto.lexicon.schema/com.atproto.label.defs", 11 + "cid": "bafyreig4hmnb2xkecyg4aaqfhr2rrcxxb3gsr4xks4rqb7rscrycalbrji" 12 + }, 5 13 "com.atproto.repo.strongRef": { 6 14 "uri": "at://did:plc:6msi3pj7krzih5qxqtryxlzw/com.atproto.lexicon.schema/com.atproto.repo.strongRef", 7 15 "cid": "bafyreifrkdbnkvfjujntdaeigolnrjj3srrs53tfixjhmacclps72qlov4"
+67
lexicons/app/bsky/actor/profile.json
··· 1 + { 2 + "id": "app.bsky.actor.profile", 3 + "defs": { 4 + "main": { 5 + "key": "literal:self", 6 + "type": "record", 7 + "record": { 8 + "type": "object", 9 + "properties": { 10 + "avatar": { 11 + "type": "blob", 12 + "accept": ["image/png", "image/jpeg"], 13 + "maxSize": 1000000, 14 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" 15 + }, 16 + "banner": { 17 + "type": "blob", 18 + "accept": ["image/png", "image/jpeg"], 19 + "maxSize": 1000000, 20 + "description": "Larger horizontal image to display behind profile view." 21 + }, 22 + "labels": { 23 + "refs": ["com.atproto.label.defs#selfLabels"], 24 + "type": "union", 25 + "description": "Self-label values, specific to the Bluesky application, on the overall account." 26 + }, 27 + "website": { 28 + "type": "string", 29 + "format": "uri" 30 + }, 31 + "pronouns": { 32 + "type": "string", 33 + "maxLength": 200, 34 + "description": "Free-form pronouns text.", 35 + "maxGraphemes": 20 36 + }, 37 + "createdAt": { 38 + "type": "string", 39 + "format": "datetime" 40 + }, 41 + "pinnedPost": { 42 + "ref": "com.atproto.repo.strongRef", 43 + "type": "ref" 44 + }, 45 + "description": { 46 + "type": "string", 47 + "maxLength": 2560, 48 + "description": "Free-form profile description text.", 49 + "maxGraphemes": 256 50 + }, 51 + "displayName": { 52 + "type": "string", 53 + "maxLength": 640, 54 + "maxGraphemes": 64 55 + }, 56 + "joinedViaStarterPack": { 57 + "ref": "com.atproto.repo.strongRef", 58 + "type": "ref" 59 + } 60 + } 61 + }, 62 + "description": "A declaration of a Bluesky account profile." 63 + } 64 + }, 65 + "$type": "com.atproto.lexicon.schema", 66 + "lexicon": 1 67 + }
+163
lexicons/com/atproto/label/defs.json
··· 1 + { 2 + "id": "com.atproto.label.defs", 3 + "defs": { 4 + "label": { 5 + "type": "object", 6 + "required": ["src", "uri", "val", "cts"], 7 + "properties": { 8 + "cid": { 9 + "type": "string", 10 + "format": "cid", 11 + "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." 12 + }, 13 + "cts": { 14 + "type": "string", 15 + "format": "datetime", 16 + "description": "Timestamp when this label was created." 17 + }, 18 + "exp": { 19 + "type": "string", 20 + "format": "datetime", 21 + "description": "Timestamp at which this label expires (no longer applies)." 22 + }, 23 + "neg": { 24 + "type": "boolean", 25 + "description": "If true, this is a negation label, overwriting a previous label." 26 + }, 27 + "sig": { 28 + "type": "bytes", 29 + "description": "Signature of dag-cbor encoded label." 30 + }, 31 + "src": { 32 + "type": "string", 33 + "format": "did", 34 + "description": "DID of the actor who created this label." 35 + }, 36 + "uri": { 37 + "type": "string", 38 + "format": "uri", 39 + "description": "AT URI of the record, repository (account), or other resource that this label applies to." 40 + }, 41 + "val": { 42 + "type": "string", 43 + "maxLength": 128, 44 + "description": "The short string name of the value or type of this label." 45 + }, 46 + "ver": { 47 + "type": "integer", 48 + "description": "The AT Protocol version of the label object." 49 + } 50 + }, 51 + "description": "Metadata tag on an atproto resource (eg, repo or record)." 52 + }, 53 + "selfLabel": { 54 + "type": "object", 55 + "required": ["val"], 56 + "properties": { 57 + "val": { 58 + "type": "string", 59 + "maxLength": 128, 60 + "description": "The short string name of the value or type of this label." 61 + } 62 + }, 63 + "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel." 64 + }, 65 + "labelValue": { 66 + "type": "string", 67 + "knownValues": [ 68 + "!hide", 69 + "!no-promote", 70 + "!warn", 71 + "!no-unauthenticated", 72 + "dmca-violation", 73 + "doxxing", 74 + "porn", 75 + "sexual", 76 + "nudity", 77 + "nsfl", 78 + "gore" 79 + ] 80 + }, 81 + "selfLabels": { 82 + "type": "object", 83 + "required": ["values"], 84 + "properties": { 85 + "values": { 86 + "type": "array", 87 + "items": { 88 + "ref": "#selfLabel", 89 + "type": "ref" 90 + }, 91 + "maxLength": 10 92 + } 93 + }, 94 + "description": "Metadata tags on an atproto record, published by the author within the record." 95 + }, 96 + "labelValueDefinition": { 97 + "type": "object", 98 + "required": ["identifier", "severity", "blurs", "locales"], 99 + "properties": { 100 + "blurs": { 101 + "type": "string", 102 + "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", 103 + "knownValues": ["content", "media", "none"] 104 + }, 105 + "locales": { 106 + "type": "array", 107 + "items": { 108 + "ref": "#labelValueDefinitionStrings", 109 + "type": "ref" 110 + } 111 + }, 112 + "severity": { 113 + "type": "string", 114 + "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 115 + "knownValues": ["inform", "alert", "none"] 116 + }, 117 + "adultOnly": { 118 + "type": "boolean", 119 + "description": "Does the user need to have adult content enabled in order to configure this label?" 120 + }, 121 + "identifier": { 122 + "type": "string", 123 + "maxLength": 100, 124 + "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 125 + "maxGraphemes": 100 126 + }, 127 + "defaultSetting": { 128 + "type": "string", 129 + "default": "warn", 130 + "description": "The default setting for this label.", 131 + "knownValues": ["ignore", "warn", "hide"] 132 + } 133 + }, 134 + "description": "Declares a label value and its expected interpretations and behaviors." 135 + }, 136 + "labelValueDefinitionStrings": { 137 + "type": "object", 138 + "required": ["lang", "name", "description"], 139 + "properties": { 140 + "lang": { 141 + "type": "string", 142 + "format": "language", 143 + "description": "The code of the language these strings are written in." 144 + }, 145 + "name": { 146 + "type": "string", 147 + "maxLength": 640, 148 + "description": "A short human-readable name for the label.", 149 + "maxGraphemes": 64 150 + }, 151 + "description": { 152 + "type": "string", 153 + "maxLength": 100000, 154 + "description": "A longer description of what the label means and why it might be applied.", 155 + "maxGraphemes": 10000 156 + } 157 + }, 158 + "description": "Strings which describe the label in the UI, localized into a specific language." 159 + } 160 + }, 161 + "$type": "com.atproto.lexicon.schema", 162 + "lexicon": 1 163 + }
+2 -1
package.json
··· 43 43 "@atproto/common": "0.5.10", 44 44 "@atproto/lex": "0.0.13", 45 45 "@atproto/oauth-client-node": "^0.3.15", 46 + "@atproto/syntax": "0.4.3", 46 47 "@deno/doc": "jsr:^0.189.1", 47 48 "@floating-ui/vue": "1.1.10", 48 49 "@iconify-json/carbon": "1.2.18", ··· 75 76 "defu": "6.1.4", 76 77 "fast-npm-meta": "1.0.0", 77 78 "focus-trap": "^7.8.0", 78 - "tinyglobby": "0.2.15", 79 79 "marked": "17.0.1", 80 80 "module-replacements": "2.11.0", 81 81 "nuxt": "4.3.0", ··· 88 88 "simple-git": "3.30.0", 89 89 "spdx-license-list": "6.11.0", 90 90 "std-env": "3.10.0", 91 + "tinyglobby": "0.2.15", 91 92 "ufo": "1.6.3", 92 93 "unocss": "66.6.0", 93 94 "unplugin-vue-router": "0.19.2",
+3
pnpm-lock.yaml
··· 32 32 '@atproto/oauth-client-node': 33 33 specifier: ^0.3.15 34 34 version: 0.3.16 35 + '@atproto/syntax': 36 + specifier: 0.4.3 37 + version: 0.4.3 35 38 '@deno/doc': 36 39 specifier: jsr:^0.189.1 37 40 version: '@jsr/deno__doc@0.189.1(patch_hash=24f326e123c822a07976329a5afe91a8713e82d53134b5586625b72431c87832)'
+68 -42
server/api/auth/atproto.get.ts
··· 6 6 import { SLINGSHOT_HOST } from '#shared/utils/constants' 7 7 import { useServerSession } from '#server/utils/server-session' 8 8 import type { PublicUserSession } from '#shared/schemas/publicUserSession' 9 + import { handleResolver } from '#server/utils/atproto/oauth' 10 + import { Client } from '@atproto/lex' 11 + import * as app from '#shared/types/lexicons/app' 12 + import { ensureValidAtIdentifier } from '@atproto/syntax' 9 13 10 - interface ProfileRecord { 11 - avatar?: { 12 - $type: 'blob' 13 - ref: { $link: string } 14 - mimeType: string 15 - size: number 14 + /** 15 + * Fetch the user's profile record to get their avatar blob reference 16 + * @param did 17 + * @param pds 18 + * @returns 19 + */ 20 + async function getAvatar(did: string, pds: string) { 21 + let avatar: string | undefined 22 + try { 23 + const pdsUrl = new URL(pds) 24 + // Only fetch from HTTPS PDS endpoints to prevent SSRF 25 + if (did && pdsUrl.protocol === 'https:') { 26 + ensureValidAtIdentifier(did) 27 + const client = new Client(pdsUrl) 28 + const profileResponse = await client.get(app.bsky.actor.profile, { 29 + repo: did, 30 + rkey: 'self', 31 + }) 32 + 33 + const validatedResponse = app.bsky.actor.profile.main.validate(profileResponse.value) 34 + 35 + if (validatedResponse.avatar?.ref) { 36 + // Use Bluesky CDN for faster image loading 37 + avatar = `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${validatedResponse.avatar?.ref}@jpeg` 38 + } 39 + } 40 + } catch { 41 + // Avatar fetch failed, continue without it 16 42 } 43 + return avatar 17 44 } 18 45 19 46 export default defineEventHandler(async event => { ··· 35 62 sessionStore, 36 63 clientMetadata, 37 64 requestLock: getOAuthLock(), 65 + handleResolver, 38 66 }) 39 67 40 68 if (!query.code) { 41 - const handle = query.handle?.toString() 42 - const create = query.create?.toString() 69 + try { 70 + const handle = query.handle?.toString() 71 + const create = query.create?.toString() 43 72 44 - if (!handle) { 45 - throw createError({ 46 - status: 400, 47 - message: 'Handle not provided in query', 73 + if (!handle) { 74 + throw createError({ 75 + statusCode: 401, 76 + message: 'Handle not provided in query', 77 + }) 78 + } 79 + 80 + const redirectUrl = await atclient.authorize(handle, { 81 + scope, 82 + prompt: create ? 'create' : undefined, 48 83 }) 49 - } 84 + return sendRedirect(event, redirectUrl.toString()) 85 + } catch (error) { 86 + const message = error instanceof Error ? error.message : 'Authentication failed.' 50 87 51 - const redirectUrl = await atclient.authorize(handle, { 52 - scope, 53 - prompt: create ? 'create' : undefined, 54 - }) 55 - return sendRedirect(event, redirectUrl.toString()) 88 + return handleApiError(error, { 89 + statusCode: 401, 90 + message: `${message}. Please login and try again.`, 91 + }) 92 + } 56 93 } 57 94 58 95 const { session: authSession } = await atclient.callback( ··· 68 105 if (response.ok) { 69 106 const miniDoc: PublicUserSession = await response.json() 70 107 71 - // Fetch the user's profile record to get their avatar blob reference 72 - let avatar: string | undefined 73 - const did = agent.did 74 - try { 75 - const pdsUrl = new URL(miniDoc.pds) 76 - // Only fetch from HTTPS PDS endpoints to prevent SSRF 77 - if (did && pdsUrl.protocol === 'https:') { 78 - const profileResponse = await fetch( 79 - `${pdsUrl.origin}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, 80 - { headers: { 'User-Agent': 'npmx' } }, 81 - ) 82 - if (profileResponse.ok) { 83 - const record = (await profileResponse.json()) as { value: ProfileRecord } 84 - const avatarBlob = record.value.avatar 85 - if (avatarBlob?.ref?.$link) { 86 - // Use Bluesky CDN for faster image loading 87 - avatar = `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${avatarBlob.ref.$link}@jpeg` 88 - } 89 - } 90 - } 91 - } catch { 92 - // Avatar fetch failed, continue without it 93 - } 108 + let avatar: string | undefined = await getAvatar(authSession.did, miniDoc.pds) 94 109 95 110 await session.update({ 96 111 public: { ··· 98 113 avatar, 99 114 }, 100 115 }) 116 + } else { 117 + //If slingshot fails we still want to set some key info we need. 118 + const pdsBase = (await authSession.getTokenInfo()).aud 119 + let avatar: string | undefined = await getAvatar(authSession.did, pdsBase) 120 + await session.update({ 121 + public: { 122 + did: authSession.did, 123 + handle: 'Not available', 124 + pds: pdsBase, 125 + avatar, 126 + }, 127 + }) 101 128 } 102 - 103 129 return sendRedirect(event, '/') 104 130 })
+2 -1
server/api/auth/session.get.ts
··· 1 1 import { PublicUserSessionSchema } from '#shared/schemas/publicUserSession' 2 2 import { safeParse } from 'valibot' 3 3 4 - export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => { 4 + export default defineEventHandler(async event => { 5 + const serverSession = await useServerSession(event) 5 6 const result = safeParse(PublicUserSessionSchema, serverSession.data.public) 6 7 if (!result.success) { 7 8 return null
+12 -3
server/utils/atproto/oauth-session-store.ts
··· 17 17 18 18 async set(_key: string, val: NodeSavedSession) { 19 19 // We are ignoring the key since the mapping is already done in the session 20 - await this.session.update({ 21 - oauthSession: val, 22 - }) 20 + try { 21 + await this.session.update({ 22 + oauthSession: val, 23 + }) 24 + } catch (error) { 25 + // Not sure if this has been happening. But helps with debugging 26 + console.error( 27 + '[oauth session store] Failed to set session:', 28 + error instanceof Error ? error.message : 'Unknown error', 29 + ) 30 + throw error 31 + } 23 32 } 24 33 25 34 async del() {
+21 -12
server/utils/atproto/oauth.ts
··· 1 1 import type { OAuthClientMetadataInput, OAuthSession } from '@atproto/oauth-client-node' 2 2 import type { EventHandlerRequest, H3Event, SessionManager } from 'h3' 3 - import { NodeOAuthClient } from '@atproto/oauth-client-node' 3 + import { NodeOAuthClient, AtprotoDohHandleResolver } from '@atproto/oauth-client-node' 4 4 import { parse } from 'valibot' 5 5 import { getOAuthLock } from '#server/utils/atproto/lock' 6 6 import { useOAuthStorage } from '#server/utils/atproto/storage' ··· 10 10 import { clientUri } from '#oauth/config' 11 11 // TODO: If you add writing a new record you will need to add a scope for it 12 12 export const scope = `atproto ${LIKES_SCOPE}` 13 + 14 + /** 15 + * Resolves a did to a handle via DoH or via the http website calls 16 + */ 17 + export const handleResolver = new AtprotoDohHandleResolver({ 18 + dohEndpoint: 'https://cloudflare-dns.com/dns-query', 19 + }) 13 20 14 21 export function getOauthClientMetadata() { 15 22 const dev = import.meta.dev ··· 42 49 serverSession: SessionManager, 43 50 ) => Promise<D> 44 51 45 - async function getOAuthSession(event: H3Event): Promise<OAuthSession | undefined> { 52 + async function getOAuthSession( 53 + event: H3Event, 54 + ): Promise<{ oauthSession: OAuthSession | undefined; serverSession: SessionManager }> { 55 + const serverSession = await useServerSession(event) 56 + 46 57 try { 47 58 const clientMetadata = getOauthClientMetadata() 48 - const serverSession = await useServerSession(event) 49 59 const { stateStore, sessionStore } = useOAuthStorage(serverSession) 50 60 51 61 const client = new NodeOAuthClient({ ··· 53 63 sessionStore, 54 64 clientMetadata, 55 65 requestLock: getOAuthLock(), 66 + handleResolver, 56 67 }) 57 68 58 - const currentSession = await sessionStore.get() 59 - if (!currentSession) return undefined 69 + const currentSession = serverSession.data 70 + if (!currentSession) return { oauthSession: undefined, serverSession } 60 71 61 - // restore using the subject 62 - return await client.restore(currentSession.tokenSet.sub) 72 + const oauthSession = await client.restore(currentSession.public.did) 73 + return { oauthSession, serverSession } 63 74 } catch (error) { 64 75 // Log error safely without using util.inspect on potentially problematic objects 65 76 // The @atproto library creates error objects with getters that crash Node's util.inspect ··· 68 79 '[oauth] Failed to get session:', 69 80 error instanceof Error ? error.message : 'Unknown error', 70 81 ) 71 - return undefined 82 + return { oauthSession: undefined, serverSession } 72 83 } 73 84 } 74 85 ··· 93 104 handler: EventHandlerWithOAuthSession<T, D>, 94 105 ) { 95 106 return defineEventHandler(async event => { 96 - const serverSession = await useServerSession(event) 97 - 98 - const oAuthSession = await getOAuthSession(event) 99 - return await handler(event, oAuthSession, serverSession) 107 + const { oauthSession, serverSession } = await getOAuthSession(event) 108 + return await handler(event, oauthSession, serverSession) 100 109 }) 101 110 }