+1
.env.template
+1
.env.template
···
1
+
VITE_OAUTH_DOMAIN={yourdomain.com}
+21
README.md
+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
+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
+1
package.json
+28
pnpm-lock.yaml
+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
+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
-9
src/counter.js
+39
-32
src/main.js
+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
+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
+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 }