Skip to content

Commit dce80cb

Browse files
authored
Merge pull request #3 from fatfingers23/feat/atproto-oauth
follow up on items on main pr
2 parents 9102628 + d01037a commit dce80cb

12 files changed

Lines changed: 96 additions & 58 deletions

File tree

app/components/AuthModal.vue

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ async function handleLogin() {
7979
<div class="flex items-center gap-3 p-4 bg-bg-subtle border border-border rounded-lg">
8080
<span class="w-3 h-3 rounded-full bg-green-500" aria-hidden="true" />
8181
<div>
82-
<p class="font-mono text-xs text-fg-muted">Logged in as @{{ user.handle }}</p>
82+
<p class="font-mono text-xs text-fg-muted">Connected as @{{ user.handle }}</p>
8383
</div>
8484
</div>
8585
<button
@@ -92,7 +92,7 @@ async function handleLogin() {
9292

9393
<!-- Disconnected state -->
9494
<form v-else class="space-y-4" @submit.prevent="handleLogin">
95-
<p class="text-sm text-fg-muted">Login with your Atmosphere account</p>
95+
<p class="text-sm text-fg-muted">Connect with your Atmosphere account</p>
9696

9797
<div class="space-y-3">
9898
<div>
@@ -122,15 +122,17 @@ async function handleLogin() {
122122
</summary>
123123
<div class="mt-3">
124124
<p>
125-
<span class="font-bold">npmx.dev</span> is built on the
125+
<span class="font-bold">npmx.dev</span> uses the
126126
<a
127127
href="https://atproto.com"
128128
target="_blank"
129129
class="text-blue-400 hover:underline"
130130
>
131-
AT Protocol </a
132-
>, allowing users to own their data and use one account for all compatible
133-
applications. Once you create an account, you can use other apps like
131+
AT Protocol
132+
</a>
133+
to power many of its social features, allowing users to own their data and use
134+
one account for all compatible applications. Once you create an account, you
135+
can use other apps like
134136
<a
135137
href="https://bsky.app"
136138
target="_blank"
@@ -146,7 +148,7 @@ async function handleLogin() {
146148
>
147149
Tangled
148150
</a>
149-
with the same login.
151+
with the same account.
150152
</p>
151153
</div>
152154
</details>
@@ -157,7 +159,7 @@ async function handleLogin() {
157159
:disabled="!handleInput.trim()"
158160
class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 focus-visible:ring-offset-2 focus-visible:ring-offset-bg"
159161
>
160-
Login
162+
Connect
161163
</button>
162164
<button
163165
type="button"
@@ -172,7 +174,7 @@ async function handleLogin() {
172174
@click="handleBlueskySignIn"
173175
class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 focus-visible:ring-offset-2 focus-visible:ring-offset-bg flex items-center justify-center gap-2"
174176
>
175-
Sign in with Bluesky
177+
Connect with Bluesky
176178
<svg fill="none" viewBox="0 0 64 57" width="20" style="width: 20px">
177179
<path
178180
fill="#0F73FF"

app/composables/useAtproto.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,19 @@
1-
type MiniDoc = {
2-
did: string
3-
handle: string
4-
pds: string
5-
}
1+
import type { UserSession } from '#shared/schemas/userSession'
62

73
/** @public */
84
export async function useAtproto() {
95
const {
106
data: user,
117
pending,
128
clear,
13-
} = await useAsyncData<MiniDoc | null>('user-state', async () => {
14-
const data = await useRequestFetch()<MiniDoc>('/api/auth/session', {
9+
} = await useAsyncData<UserSession | null>('user-state', async () => {
10+
return await useRequestFetch()<UserSession>('/api/auth/session', {
1511
headers: { accept: 'application/json' },
1612
})
17-
18-
return data
1913
})
2014

2115
const logout = async () => {
22-
await useRequestFetch()<MiniDoc>('/api/auth/session', {
16+
await useRequestFetch()<UserSession>('/api/auth/session', {
2317
method: 'delete',
2418
headers: { accept: 'application/json' },
2519
})

nuxt.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ export default defineNuxtConfig({
5151

5252
devtools: { enabled: true },
5353

54+
devServer: {
55+
// Used with atproto oauth
56+
// https://atproto.com/specs/oauth#localhost-client-development
57+
host: '127.0.0.1',
58+
},
59+
5460
app: {
5561
head: {
5662
htmlAttrs: { lang: 'en-US' },

server/api/auth/atproto.get.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { Agent } from '@atproto/api'
22
import { NodeOAuthClient } from '@atproto/oauth-client-node'
33
import { createError, getQuery, sendRedirect } from 'h3'
4-
import { OAuthSessionStore, OAuthStateStore } from '#server/utils/atproto/storage'
4+
import { useOAuthStorage } from '#server/utils/atproto/storage'
55
import { SLINGSHOT_ENDPOINT } from '#shared/utils/constants'
6+
import type { UserSession } from '#shared/schemas/userSession'
67

78
export default defineEventHandler(async event => {
89
const config = useRuntimeConfig(event)
@@ -15,8 +16,8 @@ export default defineEventHandler(async event => {
1516

1617
const query = getQuery(event)
1718
const clientMetadata = getOauthClientMetadata()
18-
const stateStore = new OAuthStateStore(event)
19-
const sessionStore = new OAuthSessionStore(event)
19+
const { stateStore, sessionStore } = useOAuthStorage(event)
20+
2021
const atclient = new NodeOAuthClient({
2122
stateStore,
2223
sessionStore,
@@ -55,7 +56,7 @@ export default defineEventHandler(async event => {
5556
`${SLINGSHOT_ENDPOINT}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${agent.did}`,
5657
{ headers: { 'User-Agent': 'npmx' } },
5758
)
58-
const miniDoc = (await response.json()) as { did: string; handle: string; pds: string }
59+
const miniDoc = (await response.json()) as UserSession
5960

6061
await session.update(miniDoc)
6162

server/api/auth/session.delete.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,6 @@
1-
export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
2-
const config = useRuntimeConfig(event)
3-
if (!config.sessionPassword) {
4-
throw createError({
5-
status: 500,
6-
message: 'NUXT_SESSION_PASSWORD not set',
7-
})
8-
}
9-
10-
const session = await useSession(event, {
11-
password: config.sessionPassword,
12-
})
13-
1+
export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => {
142
await oAuthSession?.signOut()
15-
await session.clear()
3+
await serverSession.clear()
164

175
return 'Session cleared'
186
})

server/api/auth/session.get.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
1-
export default defineEventHandler(async event => {
2-
const config = useRuntimeConfig(event)
3-
if (!config.sessionPassword) {
4-
throw createError({
5-
status: 500,
6-
message: 'NUXT_SESSION_PASSWORD not set',
7-
})
8-
}
1+
import { UserSessionSchema } from '#shared/schemas/userSession'
2+
import { safeParse } from 'valibot'
93

10-
const session = await useSession(event, {
11-
password: config.sessionPassword,
12-
})
4+
export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => {
5+
const result = safeParse(UserSessionSchema, serverSession.data)
6+
if (!result.success) {
7+
return null
8+
}
139

14-
return session.data
10+
return result.output
1511
})

server/utils/atproto/oauth.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import type { OAuthClientMetadataInput } from '@atproto/oauth-client-node'
22
import type { EventHandlerRequest, H3Event } from 'h3'
33
import type { OAuthSession } from '@atproto/oauth-client-node'
44
import { NodeOAuthClient } from '@atproto/oauth-client-node'
5-
import { OAuthSessionStore, OAuthStateStore } from '#server/utils/atproto/storage'
6-
5+
import { parse } from 'valibot'
6+
import { useOAuthStorage } from '#server/utils/atproto/storage'
7+
import { UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constants'
8+
import { OAuthMetadataSchema } from '#shared/schemas/oauth'
9+
import type { SessionManager } from 'h3'
710
// TODO: limit scope as features gets added. atproto just allows login so no scary login screen till we have scopes
811
export const scope = 'atproto'
912

@@ -18,7 +21,8 @@ export function getOauthClientMetadata() {
1821
? `http://localhost?redirect_uri=${encodeURIComponent(redirect_uri)}&scope=${encodeURIComponent(scope)}`
1922
: `${client_uri}/oauth-client-metadata.json`
2023

21-
return {
24+
// If anything changes here, please make sure to also update /shared/schemas/oauth.ts to match
25+
return parse(OAuthMetadataSchema, {
2226
client_name: 'npmx.dev',
2327
client_id,
2428
client_uri,
@@ -28,18 +32,18 @@ export function getOauthClientMetadata() {
2832
application_type: 'web',
2933
token_endpoint_auth_method: 'none',
3034
dpop_bound_access_tokens: true,
31-
} as OAuthClientMetadataInput
35+
}) as OAuthClientMetadataInput
3236
}
3337

3438
type EventHandlerWithOAuthSession<T extends EventHandlerRequest, D> = (
3539
event: H3Event<T>,
3640
session: OAuthSession | undefined,
41+
serverSession: SessionManager,
3742
) => Promise<D>
3843

3944
async function getOAuthSession(event: H3Event): Promise<OAuthSession | undefined> {
4045
const clientMetadata = getOauthClientMetadata()
41-
const stateStore = new OAuthStateStore(event)
42-
const sessionStore = new OAuthSessionStore(event)
46+
const { stateStore, sessionStore } = useOAuthStorage(event)
4347

4448
const client = new NodeOAuthClient({
4549
stateStore,
@@ -59,7 +63,20 @@ export function eventHandlerWithOAuthSession<T extends EventHandlerRequest, D>(
5963
handler: EventHandlerWithOAuthSession<T, D>,
6064
) {
6165
return defineEventHandler(async event => {
66+
const config = useRuntimeConfig(event)
67+
68+
if (!config.sessionPassword) {
69+
throw createError({
70+
status: 500,
71+
message: UNSET_NUXT_SESSION_PASSWORD,
72+
})
73+
}
74+
75+
const serverSession = await useSession(event, {
76+
password: config.sessionPassword,
77+
})
78+
6279
const oAuthSession = await getOAuthSession(event)
63-
return await handler(event, oAuthSession)
80+
return await handler(event, oAuthSession, serverSession)
6481
})
6582
}

server/utils/atproto/storage.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export class OAuthStateStore implements NodeSavedStateStore {
4545
export const OAUTH_SESSION_CACHE_STORAGE_BASE = 'oauth-atproto-session'
4646

4747
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
48+
// TODO: not sure if we will support multi accounts, but if we do in the future will need to change this around
4949
private readonly cookieKey = 'oauth:atproto:session'
5050
private readonly storage = useStorage(OAUTH_SESSION_CACHE_STORAGE_BASE)
5151

@@ -72,3 +72,10 @@ export class OAuthSessionStore implements NodeSavedSessionStore {
7272
deleteCookie(this.event, this.cookieKey)
7373
}
7474
}
75+
76+
export const useOAuthStorage = (event: H3Event) => {
77+
return {
78+
stateStore: new OAuthStateStore(event),
79+
sessionStore: new OAuthSessionStore(event),
80+
}
81+
}

shared/schemas/oauth.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { object, string, pipe, url, array, minLength, boolean } from 'valibot'
2+
import type { InferOutput } from 'valibot'
3+
4+
export const OAuthMetadataSchema = object({
5+
client_id: pipe(string(), url()),
6+
client_name: string(),
7+
client_uri: pipe(string(), url()),
8+
redirect_uris: pipe(array(string()), minLength(1)),
9+
scope: string(),
10+
grant_types: array(string()),
11+
application_type: string(),
12+
token_endpoint_auth_method: string(),
13+
dpop_bound_access_tokens: boolean(),
14+
})
15+
16+
export type OAuthMetadata = InferOutput<typeof OAuthMetadataSchema>

shared/schemas/userSession.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { object, string, pipe, url } from 'valibot'
2+
import type { InferOutput } from 'valibot'
3+
4+
export const UserSessionSchema = object({
5+
did: string(),
6+
handle: string(),
7+
pds: pipe(string(), url()),
8+
})
9+
10+
export type UserSession = InferOutput<typeof UserSessionSchema>

0 commit comments

Comments
 (0)