FeaturesTemplatesShowcaseTownie
AI
BlogDocsPricing
Log inSign up
wilhelm
wilhelmqbat
Vibecoded game with ATProto features
Public
Like
qbat
Home
Code
6
frontend
6
.vtignore
BlueSkyOAuthGuide.md
README.md
deno.json
H
index.ts
Branches
4
Pull requests
Remixes
History
Environment variables
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Sign up now
Code
/
BlueSkyOAuthGuide.md
Code
/
BlueSkyOAuthGuide.md
Search
…
Viewing readonly version of main branch: v101
View latest version
BlueSkyOAuthGuide.md

Here's a Val which describes the minimum neccessary code for client side login

Create val
/** @jsxImportSource https://esm.sh/react@19.0.0 */ import { XRPC } from "npm:@atcute/client@2.0.8"; import { configureOAuth, createAuthorizationUrl, finalizeAuthorization, getSession, OAuthUserAgent, resolveFromIdentity, } from "npm:@atcute/oauth-browser-client@1.0.15"; import { Hono } from "npm:hono@4.7.2"; import React, { useActionState, useEffect, useRef, useState } from "npm:react"; import { createRoot } from "npm:react-dom/client"; const [, , username, valname] = new URL(import.meta.url).pathname.split("/").map(s => s.toLowerCase()); const url = `https://${username}-${valname}.web.val.run`; const clientMetadata = { client_id: url + "/client-metadata.json", client_uri: url, logo_uri: url + "/favicon.svg", redirect_uris: [url], application_type: "web", client_name: "Example Browser App", dpop_bound_access_tokens: true, grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], scope: "atproto transition:chat.bsky transition:generic", token_endpoint_auth_method: "none", }; const useAtCute = () => { const [state, setState] = useState<{ agent: OAuthUserAgent; xrpc: XRPC }>(); useEffect(() => { (async () => { if (location.href.includes("state")) { const params = new URLSearchParams(location.hash.slice(1)); history.replaceState(null, "", location.pathname + location.search); const session = await finalizeAuthorization(params); const agent = new OAuthUserAgent(session); const xrpc = new XRPC({ handler: agent }); setState({ agent, xrpc }); } const sessions = localStorage.getItem("atcute-oauth:sessions"); if (!!sessions) { const did = Object.keys(JSON.parse(sessions))[0]; const session = await getSession(did, { allowStale: true }); const agent = new OAuthUserAgent(session); const xrpc = new XRPC({ handler: agent }); setState({ agent, xrpc }); } })(); }, []); return state; }; type FollowData = { data: { follows: { handle: string }[] } }; function App() { const atCute = useAtCute(); const [following, setFollowing] = useState<FollowData["data"]["follows"]>([]); const [, loginAction] = useActionState(async (_: any, formData: FormData) => { const username = formData.get("username"); const { identity, metadata } = await resolveFromIdentity(username); const authUrl = await createAuthorizationUrl({ metadata: metadata, identity: identity, scope: clientMetadata.scope, }); window.location.assign(authUrl); }, null); useEffect(() => { if (atCute?.xrpc) { window.atcute = atCute; atCute.xrpc.request({ type: "get", nsid: "app.bsky.graph.getFollows", params: { actor: atCute?.agent.session.info.sub, limit: 5, }, }).then((following: FollowData) => setFollowing(following.data.follows)); } }, [atCute]); return ( <div> <form action={loginAction}> <p>Enter your BlueSky/ATProto handle:</p> <input type="text" name="username" required placeholder="you.bsky.social"></input> <button id="login" type="submit">login</button> </form> {!!following.length && ( <div id="following"> 5 people you're following: <ul> {following.map((follow) => <li>{follow.handle}</li>)} </ul> </div> )} {!!atCute?.xrpc && ( <button onClick={async () => { await atCute.xrpc.request({ type: "get", nsid: "app.bsky.graph.getFollows", params: { actor: atCute.agent.session.info.sub, limit: 5, }, }).then((following: FollowData) => setFollowing(following.data.follows)); }} > refetch following </button> )} </div> ); } if (typeof document !== "undefined") { configureOAuth({ metadata: { client_id: clientMetadata.client_id, redirect_uri: clientMetadata.redirect_uris[0], }, }); createRoot(document.getElementById("root")!).render(<App />); } const logo = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🎯</text></svg>`; const page = ` <!DOCTYPE html> <html lang="en"> <head> <title>Username App</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml"> </head> <body> <script type="importmap"> { "imports": { "npm:@atcute/client@2.0.8": "https://esm.sh/@atcute/client@2.0.8", "npm:@atcute/oauth-browser-client@1.0.15": "https://esm.sh/@atcute/oauth-browser-client@1.0.15", "npm:hono@4.7.2": "https://esm.sh/hono@4.7.2", "npm:react": "https://esm.sh/react@19.0.0", "npm:react-dom/client": "https://esm.sh/react-dom@19.0.0/client" } } </script> <div id="root"></div> <script src="https://esm.town/v/std/catch"></script> <script type="module" src=${import.meta.url}></script> </body> </html> `; const app = new Hono(); app.get("/", c => c.html(page)); app.get("/favicon.svg", (c) => c.body(logo, { headers: { "Content-Type": "image/svg+xml" } })); app.get("/callback", (c) => c.json(c.req)); app.get("/client-metadata.json", (c) => c.json(clientMetadata)); export default app.fetch;
Go to top
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Product
FeaturesPricing
Developers
DocsStatusAPI ExamplesNPM Package Examples
Explore
ShowcaseTemplatesNewest ValsTrending ValsNewsletter
Company
AboutBlogCareersBrandhi@val.town
Terms of usePrivacy policyAbuse contact
© 2025 Val Town, Inc.