Add base auth & ucan request flow (web only)

zio/stable
Paul Frazee 2022-06-14 14:29:47 -05:00
parent 09b78a4634
commit cef133031e
14 changed files with 1555 additions and 290 deletions

1
.env 100644
View File

@ -0,0 +1 @@
REACT_APP_AUTH_LOBBY = 'http://localhost:3001'

View File

@ -14,20 +14,16 @@ Uses:
- Setup your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup). - Setup your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup).
- After initial setup: - After initial setup:
- `cd ios ; pod install` Installs the React Navigation deps ([info](https://reactnative.dev/docs/navigation#installation-and-setup)). - `cd ios ; pod install`
- To run the iOS simulator: `yarn ios` - Start the dev servers
- To run the Android simulator: `yarn android` - `yarn dev-pds`
- To run the Web app: `yarn web` - `yarn dev-wallet`
- Run the dev app
- iOS: `yarn ios`
- Android: `yarn android`
- Web: `yarn web`
- Tips - Tips
- `npx react-native info` Checks what has been installed. - `npx react-native info` Checks what has been installed.
- Android instructions are a *little* inaccurate but not as much as you might think. I had to manually create a virtual device, then run `yarn android` twice (once to start the emulator and the second time to connect to it).
## TODOs
- API
- Create mock api
- Tests
- Should just try to catch errors on basic load
## Various notes ## Various notes

View File

@ -1,5 +1,5 @@
{ {
"name": "app", "name": "pubsq",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
@ -7,11 +7,14 @@
"ios": "react-native run-ios", "ios": "react-native run-ios",
"web": "react-scripts start", "web": "react-scripts start",
"start": "react-native start", "start": "react-native start",
"dev-backend": "node ./scripts/testing-server.mjs", "dev-pds": "node ./scripts/testing-server.mjs",
"dev-wallet": "cd node_modules/\\@adxp/auth-lobby && npm run start:authed",
"test": "jest", "test": "jest",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx" "lint": "eslint . --ext .js,.jsx,.ts,.tsx"
}, },
"dependencies": { "dependencies": {
"@adxp/auth": "*",
"@adxp/common": "*",
"@react-native-async-storage/async-storage": "^1.17.6", "@react-native-async-storage/async-storage": "^1.17.6",
"@react-navigation/bottom-tabs": "^6.3.1", "@react-navigation/bottom-tabs": "^6.3.1",
"@react-navigation/native": "^6.0.10", "@react-navigation/native": "^6.0.10",
@ -29,6 +32,9 @@
"ucans": "0.9.0-alpha3" "ucans": "0.9.0-alpha3"
}, },
"devDependencies": { "devDependencies": {
"@adxp/auth-lobby": "*",
"@adxp/server": "*",
"@adxp/ws-relay": "*",
"@babel/core": "^7.12.9", "@babel/core": "^7.12.9",
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@react-native-community/eslint-config": "^2.0.0", "@react-native-community/eslint-config": "^2.0.0",

View File

@ -1,16 +1,25 @@
import {IpldStore} from '@adx/common' import {IpldStore} from '@adxp/common'
import server from '@adx/server/dist/server.js' import PDSServer from '@adxp/server/dist/server.js'
import Database from '@adx/server/dist/db/index.js' import PDSDatabase from '@adxp/server/dist/db/index.js'
import WSRelayServer from '@adxp/ws-relay/dist/index.js'
const PORT = 1986 const PDS_PORT = 2583
const WSR_PORT = 3005
async function start() { async function start() {
console.log('Initializing...') console.log('Initializing...')
const db = Database.memory() const db = PDSDatabase.memory()
const serverBlockstore = IpldStore.createInMemory() const serverBlockstore = IpldStore.createInMemory()
await db.dropTables() await db.dropTables()
await db.createTables() await db.createTables()
server(serverBlockstore, db, PORT) PDSServer(serverBlockstore, db, PDS_PORT)
if (process.argv.includes('--relay')) {
WSRelayServer(WSR_PORT)
console.log(`🔁 Relay server running on port ${WSR_PORT}`)
} else {
console.log('Include --relay to start the WS Relay')
}
} }
start() start()

48
src/api/auth.ts 100644
View File

@ -0,0 +1,48 @@
import * as auth from '@adxp/auth'
import {isWeb} from '../platform/detection'
import * as env from '../env'
const SCOPE = auth.writeCap(
'did:key:z6MkfRiFMLzCxxnw6VMrHK8pPFt4QAHS3jX3XM87y9rta6kP',
'did:example:microblog',
)
export async function isAuthed(authStore: auth.BrowserStore) {
return await authStore.hasUcan(SCOPE)
}
export async function logout(authStore: auth.BrowserStore) {
await authStore.reset()
}
export async function parseUrlForUcan() {
// @ts-ignore window is defined -prf
const fragment = window.location.hash
if (fragment.length < 1) {
return undefined
}
try {
const ucan = await auth.parseLobbyResponseHashFragment(fragment)
// @ts-ignore window is defined -prf
window.location.hash = ''
return ucan
} catch (err) {
return undefined
}
}
export async function requestAppUcan(authStore: auth.BrowserStore) {
const did = await authStore.getDid()
if (isWeb) {
// @ts-ignore window is defined -prf
const redirectTo = window.location.origin
const fragment = auth.requestAppUcanHashFragment(did, SCOPE, redirectTo)
// @ts-ignore window is defined -prf
window.location.href = `${env.AUTH_LOBBY}#${fragment}`
return false
} else {
// TODO
console.log('TODO')
}
return false
}

View File

@ -1,5 +1,26 @@
import {MicroblogDelegator, MicroblogReader, auth} from '@adx/common' // import {MicroblogDelegator, MicroblogReader, auth} from '@adx/common'
import * as ucan from 'ucans' // import * as ucan from 'ucans'
class MicroblogReader {
constructor(public url: string, public did: any) {}
}
class MicroblogDelegator {
constructor(
public url: string,
public did: any,
public keypair: any,
public ucanStore: any,
) {}
}
const auth = {
async claimFull(_one: any, _two: any) {
return {
encoded() {
return 'todo'
},
}
},
}
export class API { export class API {
userCfg?: UserConfig userCfg?: UserConfig
@ -51,9 +72,9 @@ export interface SerializedUserConfig {
export class UserConfig { export class UserConfig {
serverUrl?: string serverUrl?: string
did?: string did?: string
keypair?: ucan.EdKeypair keypair?: any //ucan.EdKeypair
rootAuthToken?: string rootAuthToken?: string
ucanStore?: ucan.Store ucanStore?: any //ucan.Store
get hasWriteCaps() { get hasWriteCaps() {
return Boolean(this.did && this.keypair && this.ucanStore) return Boolean(this.did && this.keypair && this.ucanStore)
@ -62,10 +83,10 @@ export class UserConfig {
static async createTest(serverUrl: string) { static async createTest(serverUrl: string) {
const cfg = new UserConfig() const cfg = new UserConfig()
cfg.serverUrl = serverUrl cfg.serverUrl = serverUrl
cfg.keypair = await ucan.EdKeypair.create() cfg.keypair = true //await ucan.EdKeypair.create()
cfg.did = cfg.keypair.did() cfg.did = cfg.keypair.did()
cfg.rootAuthToken = (await auth.claimFull(cfg.did, cfg.keypair)).encoded() cfg.rootAuthToken = (await auth.claimFull(cfg.did, cfg.keypair)).encoded()
cfg.ucanStore = await ucan.Store.fromTokens([cfg.rootAuthToken]) cfg.ucanStore = true // await ucan.Store.fromTokens([cfg.rootAuthToken])
return cfg return cfg
} }
@ -88,10 +109,10 @@ export class UserConfig {
async hydrate(state: SerializedUserConfig) { async hydrate(state: SerializedUserConfig) {
this.serverUrl = state.serverUrl this.serverUrl = state.serverUrl
if (state.secretKeyStr && state.rootAuthToken) { if (state.secretKeyStr && state.rootAuthToken) {
this.keypair = ucan.EdKeypair.fromSecretKey(state.secretKeyStr) this.keypair = true // ucan.EdKeypair.fromSecretKey(state.secretKeyStr)
this.did = this.keypair.did() this.did = this.keypair.did()
this.rootAuthToken = state.rootAuthToken this.rootAuthToken = state.rootAuthToken
this.ucanStore = await ucan.Store.fromTokens([this.rootAuthToken]) this.ucanStore = true // await ucan.Store.fromTokens([this.rootAuthToken])
} }
} }
} }

5
src/env.ts 100644
View File

@ -0,0 +1,5 @@
if (typeof process.env.REACT_APP_AUTH_LOBBY !== 'string') {
throw new Error('ENV: No auth lobby provided')
}
export const AUTH_LOBBY = process.env.REACT_APP_AUTH_LOBBY

View File

@ -14,7 +14,7 @@ export function Home({navigation}: RootTabsScreenProps<'Home'>) {
title="Go to Jane's profile" title="Go to Jane's profile"
onPress={() => navigation.navigate('Profile', {name: 'Jane'})} onPress={() => navigation.navigate('Profile', {name: 'Jane'})}
/> />
<Button title="Logout" onPress={() => store.session.setAuthed(false)} /> <Button title="Logout" onPress={() => store.session.logout()} />
</View> </View>
</Shell> </Shell>
) )

View File

@ -12,14 +12,9 @@ export const Login = observer(({navigation}: RootTabsScreenProps<'Login'>) => {
<View style={{justifyContent: 'center', alignItems: 'center'}}> <View style={{justifyContent: 'center', alignItems: 'center'}}>
<Text style={{fontSize: 20, fontWeight: 'bold'}}>Sign In</Text> <Text style={{fontSize: 20, fontWeight: 'bold'}}>Sign In</Text>
{store.session.uiError ?? <Text>{store.session.uiError}</Text>} {store.session.uiError ?? <Text>{store.session.uiError}</Text>}
{store.session.uiState === 'idle' ? ( {!store.session.uiIsProcessing ? (
<> <>
{store.session.hasAccount ?? ( <Button title="Login" onPress={() => store.session.login()} />
<Button
title="Login"
onPress={() => store.session.loadAccount()}
/>
)}
<Button <Button
title="Sign Up" title="Sign Up"
onPress={() => navigation.navigate('Signup')} onPress={() => navigation.navigate('Signup')}

View File

@ -13,13 +13,11 @@ export const Signup = observer(
<View style={{justifyContent: 'center', alignItems: 'center'}}> <View style={{justifyContent: 'center', alignItems: 'center'}}>
<Text style={{fontSize: 20, fontWeight: 'bold'}}>Create Account</Text> <Text style={{fontSize: 20, fontWeight: 'bold'}}>Create Account</Text>
{store.session.uiError ?? <Text>{store.session.uiError}</Text>} {store.session.uiError ?? <Text>{store.session.uiError}</Text>}
{store.session.uiState === 'idle' ? ( {!store.session.uiIsProcessing ? (
<> <>
<Button <Button
title="Create new account" title="Create new account"
onPress={() => onPress={() => store.session.login()}
store.session.createTestAccount('http://localhost:1986')
}
/> />
<Button <Button
title="Log in to an existing account" title="Log in to an existing account"

View File

@ -4,14 +4,18 @@
*/ */
import {getEnv, IStateTreeNode} from 'mobx-state-tree' import {getEnv, IStateTreeNode} from 'mobx-state-tree'
import * as auth from '@adxp/auth'
import {API} from '../api' import {API} from '../api'
export class Environment { export class Environment {
api = new API() api = new API()
authStore?: auth.BrowserStore
constructor() {} constructor() {}
async setup() {} async setup() {
this.authStore = await auth.BrowserStore.load()
}
} }
/** /**

View File

@ -6,6 +6,7 @@ import {
} from './models/root-store' } from './models/root-store'
import {Environment} from './env' import {Environment} from './env'
import * as storage from './storage' import * as storage from './storage'
import * as auth from '../api/auth'
const ROOT_STATE_STORAGE_KEY = 'root' const ROOT_STATE_STORAGE_KEY = 'root'
@ -14,6 +15,7 @@ export async function setupState() {
let data: any let data: any
const env = new Environment() const env = new Environment()
await env.setup()
try { try {
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {} data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
rootStore = RootStoreModel.create(data, env) rootStore = RootStoreModel.create(data, env)
@ -27,6 +29,16 @@ export async function setupState() {
storage.save(ROOT_STATE_STORAGE_KEY, snapshot), storage.save(ROOT_STATE_STORAGE_KEY, snapshot),
) )
if (env.authStore) {
const isAuthed = await auth.isAuthed(env.authStore)
rootStore.session.setAuthed(isAuthed)
const ucan = await auth.parseUrlForUcan()
if (ucan) {
await env.authStore.addUcan(ucan)
rootStore.session.setAuthed(true)
}
}
return rootStore return rootStore
} }

View File

@ -1,12 +1,13 @@
import {Instance, SnapshotOut, types, flow} from 'mobx-state-tree' import {Instance, SnapshotOut, types, flow} from 'mobx-state-tree'
import {UserConfig} from '../../api' // import {UserConfig} from '../../api'
import * as auth from '../../api/auth'
import {withEnvironment} from '../env' import {withEnvironment} from '../env'
export const SessionModel = types export const SessionModel = types
.model('Session') .model('Session')
.props({ .props({
isAuthed: types.boolean, isAuthed: types.boolean,
uiState: types.enumeration('idle', ['idle', 'working']), uiIsProcessing: types.maybe(types.boolean),
uiError: types.maybe(types.string), uiError: types.maybe(types.string),
// TODO: these should be stored somewhere secret // TODO: these should be stored somewhere secret
@ -14,56 +15,84 @@ export const SessionModel = types
secretKeyStr: types.maybe(types.string), secretKeyStr: types.maybe(types.string),
rootAuthToken: types.maybe(types.string), rootAuthToken: types.maybe(types.string),
}) })
.views(self => ({
get hasAccount() {
return self.serverUrl && self.secretKeyStr && self.rootAuthToken
},
}))
.extend(withEnvironment) .extend(withEnvironment)
.actions(self => ({ .actions(self => ({
setAuthed: (v: boolean) => { setAuthed: (v: boolean) => {
self.isAuthed = v self.isAuthed = v
}, },
loadAccount: flow(function* () { login: flow(function* () {
if (!self.hasAccount) { self.uiIsProcessing = true
return false
}
self.uiState = 'working'
self.uiError = undefined self.uiError = undefined
try { try {
const cfg = yield UserConfig.hydrate({ if (!self.environment.authStore) {
serverUrl: self.serverUrl, throw new Error('Auth store not initialized')
secretKeyStr: self.secretKeyStr, }
rootAuthToken: self.rootAuthToken, const res = yield auth.requestAppUcan(self.environment.authStore)
}) self.isAuthed = res
self.environment.api.setUserCfg(cfg) self.uiIsProcessing = false
return res
} catch (e: any) {
console.error('Failed to request app ucan', e)
self.uiError = e.toString()
self.uiIsProcessing = false
return false
}
}),
logout: flow(function* () {
self.uiIsProcessing = true
self.uiError = undefined
try {
if (!self.environment.authStore) {
throw new Error('Auth store not initialized')
}
const res = yield auth.logout(self.environment.authStore)
self.isAuthed = false
self.uiIsProcessing = false
return res
} catch (e: any) {
console.error('Failed to log out', e)
self.uiError = e.toString()
self.uiIsProcessing = false
return false
}
}),
/*loadAccount: flow(function* () {
self.uiIsProcessing = true
self.uiError = undefined
try {
// const cfg = yield UserConfig.hydrate({
// serverUrl: self.serverUrl,
// secretKeyStr: self.secretKeyStr,
// rootAuthToken: self.rootAuthToken,
// })
// self.environment.api.setUserCfg(cfg)
self.isAuthed = true self.isAuthed = true
self.uiState = 'idle' self.uiIsProcessing = false
return true return true
} catch (e: any) { } catch (e: any) {
console.error('Failed to create test account', e) console.error('Failed to create test account', e)
self.uiError = e.toString() self.uiError = e.toString()
self.uiState = 'idle' self.uiIsProcessing = false
return false return false
} }
}), }),
createTestAccount: flow(function* (serverUrl: string) { createTestAccount: flow(function* (_serverUrl: string) {
self.uiState = 'working' self.uiIsProcessing = true
self.uiError = undefined self.uiError = undefined
try { try {
const cfg = yield UserConfig.createTest(serverUrl) // const cfg = yield UserConfig.createTest(serverUrl)
const state = yield cfg.serialize() // const state = yield cfg.serialize()
self.serverUrl = state.serverUrl // self.serverUrl = state.serverUrl
self.secretKeyStr = state.secretKeyStr // self.secretKeyStr = state.secretKeyStr
self.rootAuthToken = state.rootAuthToken // self.rootAuthToken = state.rootAuthToken
self.isAuthed = true self.isAuthed = true
self.environment.api.setUserCfg(cfg) // self.environment.api.setUserCfg(cfg)
} catch (e: any) { } catch (e: any) {
console.error('Failed to create test account', e) console.error('Failed to create test account', e)
self.uiError = e.toString() self.uiError = e.toString()
} }
self.uiState = 'idle' self.uiIsProcessing = false
}), }),*/
})) }))
export interface Session extends Instance<typeof SessionModel> {} export interface Session extends Instance<typeof SessionModel> {}

1579
yarn.lock

File diff suppressed because it is too large Load Diff