Skip to content

Commit aa3bf51

Browse files
authored
Merge pull request #1 from fatfingers23/feat/atproto-oauth
Feat/atproto oauth
2 parents 50decf2 + a4cecc9 commit aa3bf51

10 files changed

Lines changed: 169 additions & 84 deletions

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#secure password, can use openssl rand --hex 32
2+
NUXT_SESSION_PASSWORD=""

app/composables/useRepoMeta.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,7 @@ const tangledAdapter: ProviderAdapter = {
595595
try {
596596
//Get counts of records that reference this repo in the atmosphere using constellation
597597
const allLinks = await cachedFetch<ConstellationAllLinksResponse>(
598-
`https://constellation.microcosm.blue/links/all?target=${atUri}`,
598+
`${CONSTELLATION_ENDPOINT}/links/all?target=${atUri}`,
599599
{ headers: { 'User-Agent': 'npmx' } },
600600
REPO_META_TTL,
601601
)

nuxt.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@ export default defineNuxtConfig({
124124
driver: 'fsLite',
125125
base: './.cache/fetch',
126126
},
127+
'oauth-atproto-state': {
128+
driver: 'fsLite',
129+
base: './.cache/atproto-oauth/state',
130+
},
131+
'oauth-atproto-session': {
132+
driver: 'fsLite',
133+
base: './.cache/atproto-oauth/session',
134+
},
127135
},
128136
},
129137

server/api/auth/atproto.get.ts

Lines changed: 7 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
1-
import type { H3Event } from 'h3'
21
import { Agent } from '@atproto/api'
32
import { NodeOAuthClient } from '@atproto/oauth-client-node'
4-
import type {
5-
NodeSavedSession,
6-
NodeSavedSessionStore,
7-
NodeSavedState,
8-
NodeSavedStateStore,
9-
} from '@atproto/oauth-client-node'
10-
import { scope, getOauthClientMetadata } from '~~/server/utils/atproto'
11-
import { createError, getQuery, sendRedirect, getCookie, setCookie, deleteCookie } from 'h3'
3+
import { createError, getQuery, sendRedirect } from 'h3'
4+
import { OAuthSessionStore, OAuthStateStore } from '#server/utils/atproto/storage'
5+
import { SLINGSHOT_ENDPOINT } from '#shared/utils/constants'
126

137
export default defineEventHandler(async event => {
148
const query = getQuery(event)
159
const clientMetadata = getOauthClientMetadata()
16-
const stateStore = new StateStore(event)
17-
const sessionStore = new SessionStore(event)
10+
const stateStore = new OAuthStateStore(event)
11+
const sessionStore = new OAuthSessionStore(event)
1812
const atclient = new NodeOAuthClient({
1913
stateStore,
2014
sessionStore,
@@ -46,55 +40,14 @@ export default defineEventHandler(async event => {
4640
})
4741

4842
const response = await fetch(
49-
`https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${agent.did}`,
43+
`${SLINGSHOT_ENDPOINT}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${agent.did}`,
44+
{ headers: { 'User-Agent': 'npmx' } },
5045
)
5146
const miniDoc = (await response.json()) as { did: string; handle: string; pds: string }
5247

5348
await session.update({
5449
miniDoc,
5550
})
5651

57-
await sessionStore.del()
58-
5952
return sendRedirect(event, '/')
6053
})
61-
62-
export class StateStore implements NodeSavedStateStore {
63-
private readonly stateKey = 'oauth:bluesky:stat'
64-
65-
constructor(private event: H3Event) {}
66-
67-
async get(): Promise<NodeSavedState | undefined> {
68-
const result = getCookie(this.event, this.stateKey)
69-
if (!result) return
70-
return JSON.parse(atob(result))
71-
}
72-
73-
async set(key: string, val: NodeSavedState) {
74-
setCookie(this.event, this.stateKey, btoa(JSON.stringify(val)))
75-
}
76-
77-
async del() {
78-
deleteCookie(this.event, this.stateKey)
79-
}
80-
}
81-
82-
export class SessionStore implements NodeSavedSessionStore {
83-
private readonly sessionKey = 'oauth:bluesky:session'
84-
85-
constructor(private event: H3Event) {}
86-
87-
async get(): Promise<NodeSavedSession | undefined> {
88-
const result = getCookie(this.event, this.sessionKey)
89-
if (!result) return
90-
return JSON.parse(atob(result))
91-
}
92-
93-
async set(key: string, val: NodeSavedSession) {
94-
setCookie(this.event, this.sessionKey, btoa(JSON.stringify(val)))
95-
}
96-
97-
async del() {
98-
deleteCookie(this.event, this.sessionKey)
99-
}
100-
}

server/api/auth/session.delete.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
export default defineEventHandler(async event => {
1+
import { eventHandlerWithOAuthSession } from '#server/utils/oauth'
2+
3+
export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
24
const session = await useSession(event, {
35
password: process.env.NUXT_SESSION_PASSWORD as string,
46
})
57

8+
await oAuthSession?.signOut()
69
await session.clear()
710

811
return 'Session cleared'

server/utils/atproto.ts

Lines changed: 0 additions & 28 deletions
This file was deleted.

server/utils/atproto/oauth.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { OAuthClientMetadataInput } from '@atproto/oauth-client-node'
2+
import type { EventHandlerRequest, H3Event } from 'h3'
3+
import type { OAuthSession } from '@atproto/oauth-client-node'
4+
import { NodeOAuthClient } from '@atproto/oauth-client-node'
5+
import { OAuthSessionStore, OAuthStateStore } from '#server/utils/atproto/storage'
6+
7+
// TODO: limit scope as features gets added. atproto just allows login so no scary login screen till we have scopes
8+
export const scope = 'atproto'
9+
10+
export function getOauthClientMetadata() {
11+
const dev = import.meta.dev
12+
13+
// on dev, match in nuxt.config.ts devServer: { host: "127.0.0.1" }
14+
const client_uri = dev ? `http://127.0.0.1:3000` : 'https://npmx.dev'
15+
const redirect_uri = `${client_uri}/api/auth/atproto`
16+
17+
const client_id = dev
18+
? `http://localhost?redirect_uri=${encodeURIComponent(redirect_uri)}&scope=${encodeURIComponent(scope)}`
19+
: `${client_uri}/oauth-client-metadata.json`
20+
21+
return {
22+
client_name: 'npmx.dev',
23+
client_id,
24+
client_uri,
25+
scope,
26+
redirect_uris: [redirect_uri] as [string, ...string[]],
27+
grant_types: ['authorization_code', 'refresh_token'],
28+
application_type: 'web',
29+
token_endpoint_auth_method: 'none',
30+
dpop_bound_access_tokens: true,
31+
} as OAuthClientMetadataInput
32+
}
33+
34+
type EventHandlerWithOAuthSession<T extends EventHandlerRequest, D> = (
35+
event: H3Event<T>,
36+
session: OAuthSession | undefined,
37+
) => Promise<D>
38+
39+
async function getOAuthSession(event: H3Event): Promise<OAuthSession | undefined> {
40+
const clientMetadata = getOauthClientMetadata()
41+
const stateStore = new OAuthStateStore(event)
42+
const sessionStore = new OAuthSessionStore(event)
43+
44+
const client = new NodeOAuthClient({
45+
stateStore,
46+
sessionStore,
47+
clientMetadata,
48+
})
49+
50+
const currentSession = await sessionStore.get()
51+
if (!currentSession) return undefined
52+
53+
// restore using the subject
54+
return await client.restore(currentSession.tokenSet.sub)
55+
}
56+
57+
export function eventHandlerWithOAuthSession<T extends EventHandlerRequest, D>(
58+
handler: EventHandlerWithOAuthSession<T, D>,
59+
) {
60+
return defineEventHandler(async event => {
61+
const oAuthSession = await getOAuthSession(event)
62+
return await handler(event, oAuthSession)
63+
})
64+
}

server/utils/atproto/storage.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type {
2+
NodeSavedSession,
3+
NodeSavedSessionStore,
4+
NodeSavedState,
5+
NodeSavedStateStore,
6+
} from '@atproto/oauth-client-node'
7+
import type { H3Event } from 'h3'
8+
9+
/**
10+
* Storage key prefix for oauth state storage.
11+
*/
12+
export const OAUTH_STATE_CACHE_STORAGE_BASE = 'oauth-atproto-state'
13+
14+
export class OAuthStateStore implements NodeSavedStateStore {
15+
private readonly cookieKey = 'oauth:atproto:state'
16+
private readonly storage = useStorage(OAUTH_STATE_CACHE_STORAGE_BASE)
17+
18+
constructor(private event: H3Event) {}
19+
20+
async get(): Promise<NodeSavedState | undefined> {
21+
const stateKey = getCookie(this.event, this.cookieKey)
22+
if (!stateKey) return
23+
const result = await this.storage.getItem<NodeSavedState>(stateKey)
24+
if (!result) return
25+
return result
26+
}
27+
28+
async set(key: string, val: NodeSavedState) {
29+
setCookie(this.event, this.cookieKey, key)
30+
await this.storage.setItem<NodeSavedState>(key, val)
31+
}
32+
33+
async del() {
34+
let stateKey = getCookie(this.event, this.cookieKey)
35+
deleteCookie(this.event, this.cookieKey)
36+
if (stateKey) {
37+
await this.storage.del(stateKey)
38+
}
39+
}
40+
}
41+
42+
/**
43+
* Storage key prefix for oauth session storage.
44+
*/
45+
export const OAUTH_SESSION_CACHE_STORAGE_BASE = 'oauth-atproto-session'
46+
47+
export class OAuthSessionStore implements NodeSavedSessionStore {
48+
//TODO not sure if we will support multi accounts, but if we do in the future will need to change this around
49+
private readonly cookieKey = 'oauth:atproto:session'
50+
private readonly storage = useStorage(OAUTH_SESSION_CACHE_STORAGE_BASE)
51+
52+
constructor(private event: H3Event) {}
53+
54+
async get(): Promise<NodeSavedSession | undefined> {
55+
const sessionKey = getCookie(this.event, this.cookieKey)
56+
if (!sessionKey) return
57+
let result = await this.storage.getItem<NodeSavedSession>(sessionKey)
58+
if (!result) return
59+
return result
60+
}
61+
62+
async set(key: string, val: NodeSavedSession) {
63+
setCookie(this.event, this.cookieKey, key)
64+
await this.storage.setItem<NodeSavedSession>(key, val)
65+
}
66+
67+
async del() {
68+
let sessionKey = getCookie(this.event, this.cookieKey)
69+
if (sessionKey) {
70+
await this.storage.del(sessionKey)
71+
}
72+
deleteCookie(this.event, this.cookieKey)
73+
}
74+
}

shared/utils/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export const ERROR_JSR_FETCH_FAILED = 'Failed to fetch package from JSR registry
1717
export const ERROR_NPM_FETCH_FAILED = 'Failed to fetch package from npm registry.'
1818
export const ERROR_SUGGESTIONS_FETCH_FAILED = 'Failed to fetch suggestions.'
1919

20+
// microcosm services
21+
export const CONSTELLATION_ENDPOINT = 'https://constellation.microcosm.blue'
22+
export const SLINGSHOT_ENDPOINT = 'https://slingshot.microcosm.blue'
23+
2024
// Theming
2125
export const ACCENT_COLORS = {
2226
rose: 'oklch(0.797 0.084 11.056)',

shared/utils/fetch-cache-config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* using Nitro's storage layer (backed by Vercel's runtime cache in production).
66
*/
77

8+
import { CONSTELLATION_ENDPOINT, SLINGSHOT_ENDPOINT } from './constants'
9+
810
/**
911
* Domains that should have their fetch responses cached.
1012
* Only requests to these domains will be intercepted and cached.
@@ -24,6 +26,9 @@ export const FETCH_CACHE_ALLOWED_DOMAINS = [
2426
'api.bitbucket.org', // Bitbucket API
2527
'codeberg.org', // Codeberg (Gitea-based)
2628
'gitee.com', // Gitee API
29+
//microcosm endpoints for atproto data
30+
CONSTELLATION_ENDPOINT,
31+
SLINGSHOT_ENDPOINT,
2732
] as const
2833

2934
/**

0 commit comments

Comments
 (0)