From fd13ff70e376ead6da54f6a4e029521d56d10e80 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 May 2024 15:46:25 -0500 Subject: [PATCH] Nostr OAuth actions --- src/actions/nostr.ts | 38 +++++++++++++++++++++----- src/actions/oauth.ts | 2 +- src/api/hooks/nostr/useSignerStream.ts | 14 ++++++++-- src/contexts/nostr-context.tsx | 4 +-- src/features/nostr/NConnect.ts | 7 +++-- src/reducers/meta.ts | 5 ++++ 6 files changed, 54 insertions(+), 16 deletions(-) diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts index 6908f06b6..f40f85813 100644 --- a/src/actions/nostr.ts +++ b/src/actions/nostr.ts @@ -1,14 +1,31 @@ -import { nip19 } from 'nostr-tools'; +import { RootState, type AppDispatch } from 'soapbox/store'; -import { type AppDispatch } from 'soapbox/store'; +import { authLoggedIn, verifyCredentials } from './auth'; +import { obtainOAuthToken } from './oauth'; -import { verifyCredentials } from './auth'; +const NOSTR_PUBKEY_SET = 'NOSTR_PUBKEY_SET'; /** Log in with a Nostr pubkey. */ function logInNostr(pubkey: string) { - return (dispatch: AppDispatch) => { - const npub = nip19.npubEncode(pubkey); - return dispatch(verifyCredentials(npub)); + return async (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(setNostrPubkey(pubkey)); + + const secret = sessionStorage.getItem('soapbox:nip46:secret'); + if (!secret) { + throw new Error('No secret found in session storage'); + } + + const relay = getState().instance.nostr?.relay; + + const token = await dispatch(obtainOAuthToken({ + grant_type: 'nostr', + pubkey, + relays: relay ? [relay] : undefined, + secret, + })); + + const { access_token } = dispatch(authLoggedIn(token)); + return await dispatch(verifyCredentials(access_token as string)); }; } @@ -23,4 +40,11 @@ function nostrExtensionLogIn() { }; } -export { logInNostr, nostrExtensionLogIn }; \ No newline at end of file +function setNostrPubkey(pubkey: string) { + return { + type: NOSTR_PUBKEY_SET, + pubkey, + }; +} + +export { logInNostr, nostrExtensionLogIn, setNostrPubkey, NOSTR_PUBKEY_SET }; \ No newline at end of file diff --git a/src/actions/oauth.ts b/src/actions/oauth.ts index 1c3c8a748..4147c9409 100644 --- a/src/actions/oauth.ts +++ b/src/actions/oauth.ts @@ -20,7 +20,7 @@ export const OAUTH_TOKEN_REVOKE_REQUEST = 'OAUTH_TOKEN_REVOKE_REQUEST'; export const OAUTH_TOKEN_REVOKE_SUCCESS = 'OAUTH_TOKEN_REVOKE_SUCCESS'; export const OAUTH_TOKEN_REVOKE_FAIL = 'OAUTH_TOKEN_REVOKE_FAIL'; -export const obtainOAuthToken = (params: Record, baseURL?: string) => +export const obtainOAuthToken = (params: Record, baseURL?: string) => (dispatch: AppDispatch) => { dispatch({ type: OAUTH_TOKEN_CREATE_REQUEST, params }); return baseClient(null, baseURL).post('/oauth/token', params).then(({ data: token }) => { diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useSignerStream.ts index 7685d1732..00517bd0e 100644 --- a/src/api/hooks/nostr/useSignerStream.ts +++ b/src/api/hooks/nostr/useSignerStream.ts @@ -3,11 +3,15 @@ import { useEffect, useState } from 'react'; import { useNostr } from 'soapbox/contexts/nostr-context'; import { NConnect } from 'soapbox/features/nostr/NConnect'; +const secretStorageKey = 'soapbox:nip46:secret'; + +sessionStorage.setItem(secretStorageKey, crypto.randomUUID()); + function useSignerStream() { const { relay, signer } = useNostr(); const [pubkey, setPubkey] = useState(undefined); - const storageKey = `soapbox:nostr:auth:${pubkey}`; + const authStorageKey = `soapbox:nostr:auth:${pubkey}`; useEffect(() => { if (signer) { @@ -21,8 +25,12 @@ function useSignerStream() { const connect = new NConnect({ relay, signer, - onAuthorize: (authorizedPubkey) => localStorage.setItem(storageKey, authorizedPubkey), - authorizedPubkey: localStorage.getItem(storageKey) ?? undefined, + onAuthorize(authorizedPubkey) { + localStorage.setItem(authStorageKey, authorizedPubkey); + sessionStorage.setItem(secretStorageKey, crypto.randomUUID()); + }, + authorizedPubkey: localStorage.getItem(authStorageKey) ?? undefined, + getSecret: () => sessionStorage.getItem(secretStorageKey)!, }); return () => { diff --git a/src/contexts/nostr-context.tsx b/src/contexts/nostr-context.tsx index 1338d6ac5..f138a1754 100644 --- a/src/contexts/nostr-context.tsx +++ b/src/contexts/nostr-context.tsx @@ -2,7 +2,7 @@ import { NRelay, NRelay1, NostrSigner } from '@nostrify/nostrify'; import React, { createContext, useContext, useState, useEffect, useMemo } from 'react'; import { NKeys } from 'soapbox/features/nostr/keys'; -import { useOwnAccount } from 'soapbox/hooks'; +import { useAppSelector, useOwnAccount } from 'soapbox/hooks'; import { useInstance } from 'soapbox/hooks/useInstance'; interface NostrContextType { @@ -23,7 +23,7 @@ export const NostrProvider: React.FC = ({ children }) => { const { account } = useOwnAccount(); const url = instance.nostr?.relay; - const accountPubkey = account?.nostr.pubkey; + const accountPubkey = useAppSelector((state) => account?.nostr.pubkey ?? state.meta.pubkey); const signer = useMemo( () => (accountPubkey ? NKeys.get(accountPubkey) : undefined) ?? window.nostr, diff --git a/src/features/nostr/NConnect.ts b/src/features/nostr/NConnect.ts index 8679e62bc..bfbea45da 100644 --- a/src/features/nostr/NConnect.ts +++ b/src/features/nostr/NConnect.ts @@ -5,6 +5,7 @@ interface NConnectOpts { signer: NostrSigner; authorizedPubkey: string | undefined; onAuthorize(pubkey: string): void; + getSecret(): string; } export class NConnect { @@ -13,8 +14,8 @@ export class NConnect { private signer: NostrSigner; private authorizedPubkey: string | undefined; private onAuthorize: (pubkey: string) => void; + private getSecret: () => string; - public secret = crypto.randomUUID(); private controller = new AbortController(); constructor(opts: NConnectOpts) { @@ -22,6 +23,7 @@ export class NConnect { this.signer = opts.signer; this.authorizedPubkey = opts.authorizedPubkey; this.onAuthorize = opts.onAuthorize; + this.getSecret = opts.getSecret; this.open(); } @@ -120,8 +122,7 @@ export class NConnect { private async handleConnect(pubkey: string, request: NostrConnectRequest & { method: 'connect' }) { const [remotePubkey, secret] = request.params; - if (secret === this.secret && remotePubkey === await this.signer.getPublicKey()) { - this.secret = crypto.randomUUID(); + if (secret === this.getSecret() && remotePubkey === await this.signer.getPublicKey()) { this.authorizedPubkey = pubkey; this.onAuthorize(pubkey); diff --git a/src/reducers/meta.ts b/src/reducers/meta.ts index 923a89c1b..a89f5f323 100644 --- a/src/reducers/meta.ts +++ b/src/reducers/meta.ts @@ -1,6 +1,7 @@ import { Record as ImmutableRecord } from 'immutable'; import { fetchInstance } from 'soapbox/actions/instance'; +import { NOSTR_PUBKEY_SET } from 'soapbox/actions/nostr'; import { SW_UPDATING } from 'soapbox/actions/sw'; import type { AnyAction } from 'redux'; @@ -10,6 +11,8 @@ const ReducerRecord = ImmutableRecord({ instance_fetch_failed: false, /** Whether the ServiceWorker is currently updating (and we should display a loading screen). */ swUpdating: false, + /** User's nostr pubkey. */ + pubkey: undefined as string | undefined, }); export default function meta(state = ReducerRecord(), action: AnyAction) { @@ -21,6 +24,8 @@ export default function meta(state = ReducerRecord(), action: AnyAction) { return state; case SW_UPDATING: return state.set('swUpdating', action.isUpdating); + case NOSTR_PUBKEY_SET: + return state.set('pubkey', action.pubkey); default: return state; }