Simple vanilia JS vite project with ATProto OAuth out of the box

tada

+1
.env.template
··· 1 + VITE_OAUTH_DOMAIN={yourdomain.com}
+1
.gitignore
··· 22 22 *.njsproj 23 23 *.sln 24 24 *.sw? 25 + .env
+21
README.md
··· 1 + # Simple ATProto OAuth 2 + 3 + Simple ATProto OAuth example project using Vite and vanilla JS. 4 + 5 + 6 + # Development 7 + - `pnpm install` or even `npm install` 8 + - `pnpm run dev` 9 + - By default, the OAuth client is the local dev one. Need to make sure you access from [http://127.0.0.1:5173](http://127.0.0.1:5173) for it to work properly. 10 + - If you want to change the oauth scopes they are at the top of [./src/main.js](./src/main.js) 11 + - Very simple vanilla JS app show casing how to use OAuth with ATProto. 12 + - On login sets `window.atpAgent` which is an authenticated ATProto agent to make atproto calls. 13 + 14 + 15 + # Production/Running with a domain 16 + If you are running this in production or with a public ascessible domain need to make a few changes. 17 + - Set change the text `{yourdomain.com}` in [./public/oauth-client-metadata.json](./public/oauth-client-metadata.json) in `redirect_uris` and `client_id` to your domain. 18 + - If you want to change any of the oauth scopes that's also done in [./public/oauth-client-metadata.json](./public/oauth-client-metadata.json) and loaded into the client. 19 + - Make a copy of [.env.template](.env.template) and rename it to `.env` set the domain without https for `VITE_OAUTH_DOMAIN` 20 + - If you're using vite to host it to test with something like ngrok/cloudflared you need to set the domain in [vite.config.js](vite.config.js) where it says `yourdomain.com` 21 + - Make sure it's accessible from the internet. The PDS needs to call it. Also, if you keep this same design all routes need to go to the `index.html`
+1 -1
index.html
··· 4 4 <meta charset="UTF-8" /> 5 5 <link rel="icon" type="image/svg+xml" href="/vite.svg" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 - <title>atp-oauth-playground</title> 7 + <title>ATProto OAuth Playground</title> 8 8 </head> 9 9 <body> 10 10 <div id="app"></div>
+1
package.json
··· 12 12 "vite": "^7.2.4" 13 13 }, 14 14 "dependencies": { 15 + "@atproto/api": "^0.18.9", 15 16 "@atproto/oauth-client-browser": "^0.3.38" 16 17 } 17 18 }
+28
pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 dependencies: 11 + '@atproto/api': 12 + specifier: ^0.18.9 13 + version: 0.18.9 11 14 '@atproto/oauth-client-browser': 12 15 specifier: ^0.3.38 13 16 version: 0.3.38 ··· 38 41 39 42 '@atproto-labs/simple-store@0.3.0': 40 43 resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==} 44 + 45 + '@atproto/api@0.18.9': 46 + resolution: {integrity: sha512-ft+0+sczS0qsoxwjqO1VhCXSNG792QEr+uQ91OCc36DTa3sPtaTPL7yNOVTDyEHaYDfp8tYN4v+Pq5/bzz3EpA==} 41 47 42 48 '@atproto/common-web@0.4.8': 43 49 resolution: {integrity: sha512-2YDVTYAXmd8UStebscDglisrxT5q7qt+0Fbf2zpkOITeNEEXCeTcoE0X369/ssdPtiw4CMq2rGHDH003SO7bdQ==} ··· 347 353 '@types/estree@1.0.8': 348 354 resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 349 355 356 + await-lock@2.2.2: 357 + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} 358 + 350 359 core-js@3.47.0: 351 360 resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} 352 361 ··· 409 418 tinyglobby@0.2.15: 410 419 resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 411 420 engines: {node: '>=12.0.0'} 421 + 422 + tlds@1.261.0: 423 + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} 424 + hasBin: true 412 425 413 426 tslib@2.8.1: 414 427 resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} ··· 498 511 499 512 '@atproto-labs/simple-store@0.3.0': {} 500 513 514 + '@atproto/api@0.18.9': 515 + dependencies: 516 + '@atproto/common-web': 0.4.8 517 + '@atproto/lexicon': 0.6.0 518 + '@atproto/syntax': 0.4.2 519 + '@atproto/xrpc': 0.7.7 520 + await-lock: 2.2.2 521 + multiformats: 9.9.0 522 + tlds: 1.261.0 523 + zod: 3.25.76 524 + 501 525 '@atproto/common-web@0.4.8': 502 526 dependencies: 503 527 '@atproto/lex-data': 0.0.4 ··· 732 756 733 757 '@types/estree@1.0.8': {} 734 758 759 + await-lock@2.2.2: {} 760 + 735 761 core-js@3.47.0: {} 736 762 737 763 esbuild@0.27.2: ··· 824 850 dependencies: 825 851 fdir: 6.5.0(picomatch@4.0.3) 826 852 picomatch: 4.0.3 853 + 854 + tlds@1.261.0: {} 827 855 828 856 tslib@2.8.1: {} 829 857
+2 -2
public/oauth-client-metadata.json
··· 1 1 { 2 2 "redirect_uris": [ 3 - "https://dev.modelo.social/oauth/callback" 3 + "https://{yourdomain.com}" 4 4 ], 5 5 "response_types": [ 6 6 "code" ··· 13 13 "token_endpoint_auth_method": "none", 14 14 "application_type": "web", 15 15 "subject_type": "public", 16 - "client_id": "https://dev.modelo.social/oauth-client-metadata.json", 16 + "client_id": "https://{yourdomain.com}/oauth-client-metadata.json", 17 17 "dpop_bound_access_tokens": true 18 18 }
-9
src/counter.js
··· 1 - export function setupCounter(element) { 2 - let counter = 0 3 - const setCounter = (count) => { 4 - counter = count 5 - element.innerHTML = `count is ${counter}` 6 - } 7 - element.addEventListener('click', () => setCounter(counter + 1)) 8 - setCounter(0) 9 - }
+39 -32
src/main.js
··· 1 1 import './style.css' 2 - import javascriptLogo from './javascript.svg' 3 - import viteLogo from '/vite.svg' 4 - import { setupCounter } from './counter.js' 5 - import clientMetadata from '/oauth-client-metadata.json?url&raw' 2 + import { atprotoLoopbackClientMetadata, BrowserOAuthClient } from '@atproto/oauth-client-browser' 3 + import {showError, showLoggedInPage, showLoginForm} from "./ui.js"; 4 + import { Agent } from '@atproto/api' 5 + import clientMetadataUrl from '/oauth-client-metadata.json?url' 6 6 7 + // For localhost development 8 + const scopes = ['atproto', 'transition:generic'] 9 + const redirectUri = 'http://127.0.0.1:5173/callback' 10 + const devClientId = `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scopes.join(' '))}` 7 11 8 - import {atprotoLoopbackClientMetadata, BrowserOAuthClient} from '@atproto/oauth-client-browser' 9 12 10 - const clientId = `http://localhost?redirect_uri=${encodeURIComponent('http://127.0.0.1:5173/callback')}&scope=${encodeURIComponent('atproto transition:generic')}` 11 - console.log(clientMetadata); 12 - console.log(clientId) 13 - const client = new BrowserOAuthClient({ 13 + const client = await BrowserOAuthClient.load({ 14 14 handleResolver: 'https://bsky.social', 15 - //HACK so it shares the same client metadata as what is served 16 - clientMetadata: JSON.parse(clientMetadata) 15 + // clientId: `${location.origin}${clientMetadataUrl}` 16 + clientId: import.meta.env.VITE_OAUTH_DOMAIN ? `https://${import.meta.env.VITE_OAUTH_DOMAIN}${clientMetadataUrl}` : devClientId 17 17 }) 18 - await client.init() 19 - //Auto redirects after if successful 20 - await client.signIn('baileytownsend.dev') 21 18 22 19 23 - document.querySelector('#app').innerHTML = ` 24 - <div> 25 - <a href="https://vite.dev" target="_blank"> 26 - <img src="${viteLogo}" class="logo" alt="Vite logo" /> 27 - </a> 28 - <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript" target="_blank"> 29 - <img src="${javascriptLogo}" class="logo vanilla" alt="JavaScript logo" /> 30 - </a> 31 - <h1>Hello Vite!</h1> 32 - <div class="card"> 33 - <button id="counter" type="button"></button> 34 - </div> 35 - <p class="read-the-docs"> 36 - Click on the Vite logo to learn more 37 - </p> 38 - </div> 39 - ` 20 + window.oauthClient = client 40 21 41 - setupCounter(document.querySelector('#counter')) 22 + try { 23 + const result = await client.init() 24 + //If a result is set and there is a session, the user is authenticated or was a successful callback 25 + if (result) { 26 + const {session, state} = result 27 + if (state != null) { 28 + console.log( 29 + `${session.sub} was successfully authenticated (state: ${state})`, 30 + ) 31 + } else { 32 + console.log(`${session.sub} was restored (last active session)`) 33 + } 34 + if (session) { 35 + //This is what actually makes authenticated atproto requests 36 + window.atpAgent = new Agent(session) 37 + //Shows the logged in ui page 38 + await showLoggedInPage(session) 39 + } 40 + } else { 41 + //Shows the login form 42 + showLoginForm() 43 + } 44 + } 45 + catch (error) { 46 + console.error('OAuth client initialization error:', error) 47 + showError(error.message) 48 + }
+178 -21
src/style.css
··· 15 15 16 16 a { 17 17 font-weight: 500; 18 - color: #646cff; 18 + color: #14b8a6; 19 19 text-decoration: inherit; 20 20 } 21 21 a:hover { 22 - color: #535bf2; 22 + color: #0d9488; 23 23 } 24 24 25 25 body { ··· 31 31 } 32 32 33 33 h1 { 34 - font-size: 3.2em; 34 + font-size: 2em; 35 35 line-height: 1.1; 36 + margin-bottom: 1.5em; 36 37 } 37 38 38 39 #app { 39 - max-width: 1280px; 40 + max-width: 400px; 40 41 margin: 0 auto; 41 42 padding: 2rem; 43 + } 44 + 45 + .container { 42 46 text-align: center; 43 47 } 44 48 45 - .logo { 46 - height: 6em; 47 - padding: 1.5em; 48 - will-change: filter; 49 - transition: filter 300ms; 49 + .form-group { 50 + margin-bottom: 1rem; 51 + text-align: left; 52 + } 53 + 54 + .form-group label { 55 + display: block; 56 + margin-bottom: 0.5rem; 57 + font-weight: 500; 58 + } 59 + 60 + .form-group input { 61 + width: 100%; 62 + padding: 0.6em; 63 + font-size: 1em; 64 + font-family: inherit; 65 + border: 1px solid #444; 66 + border-radius: 4px; 67 + background-color: #1a1a1a; 68 + color: inherit; 69 + box-sizing: border-box; 70 + } 71 + 72 + .form-group input:focus { 73 + outline: none; 74 + border-color: #14b8a6; 75 + } 76 + 77 + .error { 78 + color: #ff6b6b; 79 + font-size: 0.9em; 80 + margin-top: 0.5rem; 81 + min-height: 1.2em; 82 + } 83 + 84 + .profile-card { 85 + background-color: #1a1a1a; 86 + padding: 1.5rem; 87 + border-radius: 8px; 88 + margin: 2rem 0; 89 + display: flex; 90 + align-items: center; 91 + gap: 1rem; 92 + text-align: left; 93 + } 94 + 95 + .profile-avatar { 96 + width: 80px; 97 + height: 80px; 98 + border-radius: 50%; 99 + object-fit: cover; 100 + flex-shrink: 0; 101 + } 102 + 103 + .profile-info { 104 + flex: 1; 105 + } 106 + 107 + .profile-name { 108 + margin: 0 0 0.25rem 0; 109 + font-size: 1.3em; 110 + font-weight: 600; 111 + } 112 + 113 + .profile-handle { 114 + margin: 0 0 0.75rem 0; 115 + color: #888; 116 + font-size: 0.9em; 117 + } 118 + 119 + .profile-stats { 120 + display: flex; 121 + gap: 1.5rem; 122 + font-size: 0.9em; 123 + } 124 + 125 + .profile-stats span { 126 + color: #888; 127 + } 128 + 129 + .profile-stats strong { 130 + color: inherit; 131 + font-weight: 600; 132 + } 133 + 134 + .notifications-section { 135 + margin: 2rem 0; 136 + text-align: left; 137 + } 138 + 139 + .notifications-section h3 { 140 + font-size: 1.2em; 141 + margin: 0 0 1rem 0; 142 + font-weight: 600; 50 143 } 51 - .logo:hover { 52 - filter: drop-shadow(0 0 2em #646cffaa); 144 + 145 + .notifications-list { 146 + background-color: #1a1a1a; 147 + border-radius: 8px; 148 + overflow: hidden; 53 149 } 54 - .logo.vanilla:hover { 55 - filter: drop-shadow(0 0 2em #f7df1eaa); 150 + 151 + .notification-item { 152 + display: flex; 153 + align-items: flex-start; 154 + gap: 0.75rem; 155 + padding: 1rem; 156 + border-bottom: 1px solid #333; 157 + } 158 + 159 + .notification-item:last-child { 160 + border-bottom: none; 161 + } 162 + 163 + .notification-item.unread { 164 + background-color: rgba(20, 184, 166, 0.1); 165 + } 166 + 167 + .notification-avatar { 168 + width: 40px; 169 + height: 40px; 170 + border-radius: 50%; 171 + object-fit: cover; 172 + flex-shrink: 0; 173 + } 174 + 175 + .notification-content { 176 + flex: 1; 177 + } 178 + 179 + .notification-text { 180 + margin: 0 0 0.25rem 0; 181 + font-size: 0.95em; 182 + line-height: 1.4; 56 183 } 57 184 58 - .card { 59 - padding: 2em; 185 + .notification-text strong { 186 + font-weight: 600; 60 187 } 61 188 62 - .read-the-docs { 189 + .notification-time { 190 + margin: 0; 191 + font-size: 0.85em; 63 192 color: #888; 64 193 } 65 194 195 + .session-info { 196 + background-color: #1a1a1a; 197 + padding: 1.5rem; 198 + border-radius: 8px; 199 + margin: 2rem 0; 200 + text-align: left; 201 + } 202 + 203 + .session-info p { 204 + margin: 0.5rem 0; 205 + word-break: break-all; 206 + } 207 + 66 208 button { 67 209 border-radius: 8px; 68 210 border: 1px solid transparent; ··· 70 212 font-size: 1em; 71 213 font-weight: 500; 72 214 font-family: inherit; 73 - background-color: #1a1a1a; 215 + background-color: #14b8a6; 216 + color: white; 74 217 cursor: pointer; 75 - transition: border-color 0.25s; 218 + transition: background-color 0.25s; 219 + width: 100%; 220 + margin-top: 1rem; 76 221 } 77 222 button:hover { 78 - border-color: #646cff; 223 + background-color: #0d9488; 79 224 } 80 225 button:focus, 81 226 button:focus-visible { ··· 88 233 background-color: #ffffff; 89 234 } 90 235 a:hover { 91 - color: #747bff; 236 + color: #2dd4bf; 92 237 } 93 - button { 238 + .form-group input { 94 239 background-color: #f9f9f9; 240 + border-color: #ddd; 241 + } 242 + .profile-card, 243 + .notifications-list, 244 + .session-info { 245 + background-color: #f9f9f9; 246 + } 247 + .notification-item { 248 + border-bottom-color: #e0e0e0; 249 + } 250 + .notification-item.unread { 251 + background-color: rgba(20, 184, 166, 0.05); 95 252 } 96 253 }
+139
src/ui.js
··· 1 + const appElement = document.getElementById('app') 2 + 3 + /** 4 + * Shows the login form and sets a event listener for form submission to start the OAuth flow. 5 + */ 6 + function showLoginForm() { 7 + appElement.innerHTML = ` 8 + <div class="container"> 9 + <h1>ATProto OAuth Playground</h1> 10 + <form id="login-form"> 11 + <div class="form-group"> 12 + <label for="handle">ATProto Handle</label> 13 + <input 14 + type="text" 15 + id="handle" 16 + name="handle" 17 + placeholder="jcsalterego.bsky.social" 18 + required 19 + /> 20 + <div id="error" class="error"></div> 21 + </div> 22 + <button type="submit">Sign In</button> 23 + </form> 24 + </div> 25 + ` 26 + 27 + document.querySelector('#login-form').addEventListener('submit', async (e) => { 28 + e.preventDefault() 29 + 30 + const handle = document.querySelector('#handle').value.trim() 31 + const errorEl = document.querySelector('#error') 32 + errorEl.textContent = '' 33 + 34 + try { 35 + // This will redirect to the OAuth authorization page on the PDS 36 + await window.oauthClient.signIn(handle) 37 + } catch (error) { 38 + console.error('Sign in error:', error) 39 + errorEl.textContent = error.message || 'Failed to sign in. Please check your handle and try again.' 40 + } 41 + }) 42 + } 43 + 44 + /** 45 + * Demo component to show an authenticated request by fetching the user's notifications. 46 + * @returns {Promise<string>} 47 + */ 48 + async function notificationsList(){ 49 + 50 + const notifications = await window.atpAgent.app.bsky.notification.listNotifications({ 51 + limit: 5 52 + }) 53 + 54 + return notifications.data.notifications.map(notif => { 55 + const reasonText = { 56 + 'like': 'liked your post', 57 + 'repost': 'reposted your post', 58 + 'follow': 'followed you', 59 + 'mention': 'mentioned you', 60 + 'reply': 'replied to your post', 61 + 'quote': 'quoted your post' 62 + }[notif.reason] || notif.reason 63 + 64 + return ` 65 + <div class="notification-item ${!notif.isRead ? 'unread' : ''}"> 66 + <img src="${notif.author.avatar || '/vite.svg'}" alt="${notif.author.displayName}" class="notification-avatar" /> 67 + <div class="notification-content"> 68 + <p class="notification-text"> 69 + <strong>${notif.author.displayName || notif.author.handle}</strong> ${reasonText} 70 + </p> 71 + <p class="notification-time">${new Date(notif.indexedAt).toLocaleString()}</p> 72 + </div> 73 + </div> 74 + ` 75 + }).join('') 76 + 77 + } 78 + 79 + /** 80 + * Shows the logged in page with the user's profile and notifications. 81 + */ 82 + async function showLoggedInPage(session) { 83 + const profile = await window.atpAgent.getProfile({ 84 + actor: session.sub 85 + }) 86 + 87 + const { avatar, displayName, handle, followersCount, followsCount } = profile.data 88 + 89 + 90 + appElement.innerHTML = ` 91 + <div class="container"> 92 + <h1>Logged In</h1> 93 + <div class="profile-card"> 94 + <img src="${avatar || '/vite.svg'}" alt="Profile picture" class="profile-avatar" /> 95 + <div class="profile-info"> 96 + <h2 class="profile-name">${displayName || handle}</h2> 97 + <p class="profile-handle">@${handle}</p> 98 + <div class="profile-stats"> 99 + <span><strong>${followersCount || 0}</strong> Followers</span> 100 + <span><strong>${followsCount || 0}</strong> Following</span> 101 + </div> 102 + </div> 103 + </div> 104 + <div class="notifications-section"> 105 + <h3>Recent Notifications</h3> 106 + <div class="notifications-list"> 107 + ${await notificationsList()} 108 + </div> 109 + </div> 110 + <button id="logout">Sign Out</button> 111 + </div> 112 + ` 113 + 114 + document.querySelector('#logout').addEventListener('click', async () => { 115 + try { 116 + await window.oauthClient.revoke(session.sub) 117 + showLoginForm() 118 + } catch (error) { 119 + console.error('Sign out error:', error) 120 + } 121 + }) 122 + } 123 + 124 + /** 125 + * 126 + * DANGER WILL ROBINSON 127 + */ 128 + function showError(message) { 129 + appElement.innerHTML = ` 130 + <div class="container"> 131 + <h1>ATProto OAuth Playground</h1> 132 + <div class="error">${message}</div> 133 + <a href="/">Back to login</a> 134 + </div> 135 + ` 136 + } 137 + 138 + 139 + export { showLoginForm, showLoggedInPage, showError }
+1 -2
vite.config.js
··· 2 2 // config options 3 3 server: { 4 4 host: '0.0.0.0', 5 - assetsInclude: ['public/oauth-client-metadata.json'], 6 - allowedHosts: ['baileys-mac-studio.tailda2966.ts.net', 'dev.modelo.social'] 5 + allowedHosts: ['yourdomain.com'] 7 6 } 8 7 }