Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

App owned logins #330

Merged
merged 2 commits into from
Dec 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
Apps need to update to this webnative version to load migrated/new filesystems.
- Adds ability to share private files.
- Adds soft/symbolic links.
- Add dependency injection for initialise and registering accounts



Expand Down
18 changes: 18 additions & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { impl } from "../setup/dependencies.js"
import { InitOptions, State } from "../index.js"

export const init = (options: InitOptions): Promise<State | null> => {
return impl.auth.init(options)
}

export const register = (options: { username: string; email: string }): Promise<{success: boolean}> => {
return impl.auth.register(options)
}

export const isUsernameValid = (username: string): Promise<boolean> => {
return impl.auth.isUsernameValid(username)
}

export const isUsernameAvailable = (username: string): Promise<boolean> => {
return impl.auth.isUsernameAvailable(username)
}
235 changes: 235 additions & 0 deletions src/auth/lobby.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import * as identifiers from "../common/identifiers.js"
import * as common from "../common/index.js"
import { USERNAME_STORAGE_KEY } from "../common/index.js"
import * as crypto from "../crypto/index.js"
import * as did from "../did/index.js"
import { loadFileSystem } from "../filesystem.js"
import FileSystem from "../fs/index.js"
import { InitOptions, scenarioAuthCancelled, scenarioAuthSucceeded, scenarioNotAuthorised, State, validateSecrets } from "../index.js"
import * as ipfs from "../ipfs/index.js"
import * as user from "../lobby/username.js"
import * as pathing from "../path.js"
import { setup } from "../setup/internal.js"
import * as storage from "../storage/index.js"
import * as ucan from "../ucan/internal.js"

export const init = async (options: InitOptions): Promise<State | null> => {
const permissions = options.permissions || null
const { autoRemoveUrlParams = true, rootKey } = options

// TODO: should be shared?
const maybeLoadFs = async (username: string): Promise<undefined | FileSystem> => {
return options.loadFileSystem === false
? undefined
: await loadFileSystem(permissions, username, rootKey)
}

// URL things
const url = new URL(window.location.href)
const authorised = url.searchParams.get("authorised")
const cancellation = url.searchParams.get("cancelled")

// Determine scenario
if (authorised) {
const newUser = url.searchParams.get("newUser") === "t"
const username = url.searchParams.get("username") || ""

await retry(async () => importClassifiedInfo(
authorised === "via-postmessage"
? await getClassifiedViaPostMessage()
: JSON.parse(await ipfs.cat(authorised)) // in any other case we expect it to be a CID
), { tries: 10, timeout: 10000, timeoutMessage: "Trying to retrieve UCAN(s) and readKey(s) from the auth lobby timed out after 10 seconds." })

await storage.setItem(USERNAME_STORAGE_KEY, username)

if (autoRemoveUrlParams) {
url.searchParams.delete("authorised")
url.searchParams.delete("newUser")
url.searchParams.delete("username")
history.replaceState(null, document.title, url.toString())
}

if (permissions && await validateSecrets(permissions) === false) {
console.warn("Unable to validate filesystem secrets")
return scenarioNotAuthorised(permissions)
}

if (permissions && ucan.validatePermissions(permissions, username) === false) {
console.warn("Unable to validate UCAN permissions")
return scenarioNotAuthorised(permissions)
}

return scenarioAuthSucceeded(
permissions,
newUser,
username,
await maybeLoadFs(username)
)

} else if (cancellation) {
const c = (() => {
switch (cancellation) {
case "DENIED": return "User denied authorisation"
default: return "Unknown reason"
}
})()

return scenarioAuthCancelled(permissions, c)
} else {
await ucan.store([])
}

return null
}

export const register = async (options: { username: string; email: string }): Promise<{ success: boolean }> => {
return new Promise(resolve => resolve({ success: false }))
}

export const isUsernameValid = async (username: string): Promise<boolean> => {
return user.isUsernameValid(username)
}

export const isUsernameAvailable = async (username: string): Promise<boolean> => {
return user.isUsernameAvailable(username)
}

// HELPERS

async function retry(action: () => Promise<void>, options: { tries: number; timeout: number; timeoutMessage: string }): Promise<void> {
return await Promise.race([
(async () => {
let tryNum = 1
while (tryNum <= options.tries) {
try {
await action()
return
} catch (e) {
if (tryNum == options.tries) {
throw e
}
}
tryNum++
}
})(),
new Promise<void>((resolve, reject) => setTimeout(() => reject(new Error(options.timeoutMessage)), options.timeout))
])
}

interface AuthLobbyClassifiedInfo {
sessionKey: string
secrets: string
iv: string
}

function isAuthLobbyClassifiedInfo(obj: unknown): obj is AuthLobbyClassifiedInfo {
return common.isObject(obj)
&& common.isString(obj.sessionKey)
&& common.isString(obj.secrets)
&& common.isString(obj.iv)
}

async function importClassifiedInfo(
classifiedInfo: AuthLobbyClassifiedInfo
): Promise<void> {

console.log(classifiedInfo)
// Extract session key and its iv
const rawSessionKey = await crypto.keystore.decrypt(classifiedInfo.sessionKey)

// Decrypt secrets
const secretsStr = await crypto.aes.decryptGCM(classifiedInfo.secrets, rawSessionKey, classifiedInfo.iv)
const secrets = JSON.parse(secretsStr)

const fsSecrets: Record<string, { key: string; bareNameFilter: string }> = secrets.fs
const ucans = secrets.ucans

// Import read keys and bare name filters
await Promise.all(
Object.entries(fsSecrets).map(async ([posixPath, { bareNameFilter, key }]) => {
const path = pathing.fromPosix(posixPath)
const readKeyId = await identifiers.readKey({ path })
const bareNameFilterId = await identifiers.bareNameFilter({ path })

await crypto.keystore.importSymmKey(key, readKeyId)
await storage.setItem(bareNameFilterId, bareNameFilter)
})
)

// Add UCANs to the storage
await ucan.store(ucans)
}

async function getClassifiedViaPostMessage(): Promise<AuthLobbyClassifiedInfo> {
const iframe: HTMLIFrameElement = await new Promise(resolve => {
const iframe = document.createElement("iframe")
iframe.id = "webnative-secret-exchange"
iframe.style.width = "0"
iframe.style.height = "0"
iframe.style.border = "none"
iframe.style.display = "none"
document.body.appendChild(iframe)

iframe.onload = () => {
resolve(iframe)
}

iframe.src = `${setup.endpoints.lobby}/exchange.html`
})

try {

const answer: Promise<AuthLobbyClassifiedInfo> = new Promise((resolve, reject) => {
let tries = 10
window.addEventListener("message", listen)

function retryOrReject(eventData?: string) {
console.warn(`When importing UCANs & readKey(s): Can't parse: ${eventData}. Might be due to extensions.`)
if (--tries === 0) {
window.removeEventListener("message", listen)
reject(new Error("Couldn't parse message from auth lobby after 10 tries. See warnings above."))
}
}

function listen(event: MessageEvent<string>) {
if (new URL(event.origin).host !== new URL(setup.endpoints.lobby).host) {
console.log(`Got a message from ${event.origin} while waiting for login credentials. Ignoring.`)
return
}

if (event.data == null) {
// Might be an extension sending a message without data
return
}

let classifiedInfo: unknown = null
try {
classifiedInfo = JSON.parse(event.data)
} catch {
retryOrReject(event.data)
return
}

if (!isAuthLobbyClassifiedInfo(classifiedInfo)) {
retryOrReject(event.data)
return
}

window.removeEventListener("message", listen)
resolve(classifiedInfo)
}
})

if (iframe.contentWindow == null) throw new Error("Can't import UCANs & readKey(s): No access to its contentWindow")
const message = {
webnative: "exchange-secrets",
didExchange: await did.exchange()
}
iframe.contentWindow.postMessage(message, iframe.src)

return await answer

} finally {
document.body.removeChild(iframe)
}
}
37 changes: 37 additions & 0 deletions src/auth/local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { State } from "../index.js"
import { createAccount } from "../lobby/index.js"
import * as user from "../lobby/username.js"
import * as storage from "../storage/index.js"
import { USERNAME_STORAGE_KEY } from "../common/index.js"

export const init = async (): Promise<State | null> => {
console.log("initialize local auth")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could switch out console.log with the debug logging functions, so they only print with setup.debug({ enabled: true })

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setup.debug({ enabled: true })

This is also generally togglable in the console directly — no need for managing it ourselves

Screen Shot 2021-12-09 at 10 01 27

return new Promise((resolve) => resolve(null))
}

export const register = async (options: { username: string; email: string}): Promise<{success: boolean}> => {
const { success } = await createAccount(options)

if (success) {
await storage.setItem(USERNAME_STORAGE_KEY, options.username)
return { success: true }
}
return { success: false }
}

export const isUsernameValid = async (username: string): Promise<boolean> => {
return user.isUsernameValid(username)
}

export const isUsernameAvailable = async (username: string): Promise<boolean> => {
return user.isUsernameAvailable(username)
}

export const LOCAL_IMPLEMENTATION = {
auth: {
init,
register,
isUsernameValid,
isUsernameAvailable
}
}
Loading