diff --git a/src/@types/AuthenticatedUser.ts b/src/@types/AuthenticatedUser.ts index 821c61ae..3f0d1ea5 100644 --- a/src/@types/AuthenticatedUser.ts +++ b/src/@types/AuthenticatedUser.ts @@ -1,5 +1,6 @@ import type { DiscordConnection } from './mongo/User/DiscordConnection' import type { GitHubConnection } from './mongo/User/GitHubConnection' +import type { BlueskyConnection } from './mongo/User/BlueskyConnection' export interface AuthenticatedUser { /** ID in the database */ @@ -15,5 +16,6 @@ export interface AuthenticatedUser { connections: { github: GitHubConnection discord: DiscordConnection + bluesky: BlueskyConnection } } diff --git a/src/components/connections/bluesky.tsx b/src/components/connections/bluesky.tsx new file mode 100644 index 00000000..135e256a --- /dev/null +++ b/src/components/connections/bluesky.tsx @@ -0,0 +1,30 @@ +import type { FunctionComponent } from 'react' + +import type { BlueskyConnection } from '$types/mongo/User/BlueskyConnection' + +export interface Props { + connection: BlueskyConnection +} + +export const Bluesky: FunctionComponent = props => { + const { connection } = props + + return ( +
+
+

Bluesky

+

+ {connection + ? 'Connected with @' + connection.username + : 'Not connected'} +

+
+ + {connection ? 'Reconnect' : 'Connect'} + +
+ ) +} diff --git a/src/components/connections/index.tsx b/src/components/connections/index.tsx index 8ffb0b82..2752b522 100644 --- a/src/components/connections/index.tsx +++ b/src/components/connections/index.tsx @@ -1,5 +1,6 @@ import { Discord } from './discord' import { GitHub } from './github' +import { Bluesky } from './bluesky' import type { FunctionComponent } from 'react' import type { AuthenticatedUser } from '$types/AuthenticatedUser' @@ -20,6 +21,7 @@ export const Connections: FunctionComponent = props => {
+
diff --git a/src/packlets/backend/auth/authenticateBluesky.ts b/src/packlets/backend/auth/authenticateBluesky.ts new file mode 100644 index 00000000..8320889c --- /dev/null +++ b/src/packlets/backend/auth/authenticateBluesky.ts @@ -0,0 +1,96 @@ +import { ObjectId } from 'mongodb' +import { AtpApi, AtpSessionData } from '@atproto/api' + +import { collections } from '$constants/mongo' +import { blueskyClient } from '$constants/secrets/blueskyClient' + +import { getAuthenticatedUser } from './getAuthenticatedUser' +import { finalizeAuthentication } from './finalizeAuthentication' + +import type { User } from '$types/mongo/User' + +interface BlueskyTokenResponse { + access_token: string + refresh_token: string + scope: string + token_type: string +} + +interface BlueskyUserResponse { + did: string + handle: string +} + +export const authenticateBluesky = async ( + code: string, + existingAuthToken?: string +) => { + const currentAuthenticatedUser = await getAuthenticatedUser(existingAuthToken) + + if (currentAuthenticatedUser === null) throw new Error('not-authenticated') + + try { + // obtain access token + console.log('[bluesky] token') + const authorization = await fetch( + 'https://bsky.social/xrpc/com.atproto.server.createSession', + { + method: 'POST', + body: JSON.stringify({ + client_id: blueskyClient.id, + client_secret: blueskyClient.secret, + code: code, + redirect_uri: 'https://creatorsgarten.org/auth/callback', + grant_type: 'authorization_code', + }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + } + ).then(o => { + if (o.ok) return o.json() as Promise + else throw o + }) + + // get user information + console.log('[bluesky] user') + const api = new AtpApi({ service: 'https://bsky.social' }) + api.setSession(authorization as AtpSessionData) + const user = await api.com.atproto.identity.resolveHandle({ + handle: authorization.did, + }).then(o => { + if (o.success) return o.data as BlueskyUserResponse + else throw o + }) + + // make sure that this account does not connected to another account + const existingUser = await collections.users.findOne({ + _id: { $ne: new ObjectId(currentAuthenticatedUser.sub) }, + 'connections.bluesky.did': user.did, + }) + if (existingUser !== null) + throw new Error('This connection has been connected to another account.') + + // sync with mongo + await collections.users.updateOne( + { uid: currentAuthenticatedUser.uid }, + { + $set: { + connections: { + ...currentAuthenticatedUser.connections, + bluesky: { + did: user.did, + handle: user.handle, + }, + }, + } satisfies Partial, + } + ) + + return finalizeAuthentication(currentAuthenticatedUser.uid) + } catch (e) { + if (e instanceof Error) throw e + else throw new Error('Unable to verify authenticity of this connection.') + } +} diff --git a/src/pages/auth/connect/bluesky.ts b/src/pages/auth/connect/bluesky.ts new file mode 100644 index 00000000..b43813b5 --- /dev/null +++ b/src/pages/auth/connect/bluesky.ts @@ -0,0 +1,25 @@ +import CSRF from 'csrf' + +import { csrfSecret } from '$constants/secrets/csrfSecret' +import { blueskyClient } from '$constants/secrets/blueskyClient' + +import type { APIRoute } from 'astro' + +export const GET: APIRoute = async ({ request, redirect }) => { + const csrfInstance = new CSRF() + const redirectHint = + new URL(request.url).hostname === 'localhost' ? 'localhost3000' : 'new' + const csrfToken = csrfInstance.create(csrfSecret ?? 'demodash') + + const loginURI = `https://bsky.social/xrpc/com.atproto.server.createSession?${new URLSearchParams( + { + client_id: blueskyClient.id ?? '', + redirect_uri: 'https://creatorsgarten.org/auth/callback', + state: `${redirectHint}!/dashboard!bluesky-${csrfToken}`, + response_type: 'code', + scope: 'openid', + } + ).toString()}` + + return redirect(loginURI, 307) +}