Skip to content

Commit

Permalink
App owned: account linking (#335)
Browse files Browse the repository at this point in the history
* Add producer and consumer modules and pin challenge

* Add account delegation and device linking

* Update user challenge naming and type

* Add consumer onCompletion callback

* Add start config types and fine tune onCompletion

Add onCompletion to producer. The producer implicitly knows it is done
after the challenge, but in the future the producer may stay active for
multiple account link requests for consumers. The producer onCompletion
will be useful then as a signal that all link requests have been
handled.

* Change delegateAccount and linkDevice types

These are the paired functions that developers will implement.
delegateAccount returns a record and linkDevice takes a record. It will
be up to developers to make sure they the records match.

* Add username to producer onCompletion

Returning the username is mostly a convenience for developers, but
moving it to the link state might prove useful for multiple accounts at
some point in the future.

Also, a small change in the consuer to reset the username on reseting
the link state. It's not clear yet whether this should also happen in
the producer, and in general whether the producer should keep listening
after a consumer has been linked.

* Convert callback interface to event emitter

Instead of expecting callbacks to handle events, the event emitter lets
a developer add event listeners. This interface is more flexible for
developers and for our internal implementation -- developers can
subscribe to events multiple times and we can add events in the future
if we want to.
At the moment, this is incomplete and we need to handle timeouts and
think about cancellation semantics. Various edge cases should also be
considered.

* Add account linking declined message

The consumer should be informed when linking was declined. A message was
added when a proder declines, and an approved field was added to the
link event of both the consumer and producer.

* Refactor to remove module global state and more

We want to avoid module global state because it may modules may be
re-instantiated in some cases, which can produce unpleasant and hard to
debug situations. This refactor also removes channel and user
interaction side effects from many of the account linking functions,
which should make them easier to test.
Lastly, the dependency injected channel functions are reduced to a
single createChannel function. It generates send, receive, and close
functions. At the moment, the receive function is unused and maybe won't
be needed.

* Short circuit dispatch when no listeners

An event is only created when we have at least one listener. This change
prevents dispatch for events when no listeners have been added.

* Add error handling

* Decouple linking functions from linking state

The functions are easier to test if they do not depend on the entire
linking state. In addition, this will make it easier to re-use them for
other AWAKE use cases.

* Export linking functions for testing

* Add unit tests and prop checks

* Add producer state transition to delegation

* Add consumer temporary key exchange retry

We can't be certain that users will open a window with the producer before
they request account linking in a consumer. The retry keeps trying until the
consumer receives a session key from a producer.
In addition, retries will be useful in cases where the producer is busy
with another consumer, at least in this version. In a future version,
the producer will queue up consumers that want to be linked.

* Add consumer broadcast state warning

There is a narrow window between when the consumer is initialized and
they have broadcast a temporary RSA key. This warning will be triggered
in that window and any message dropped.

* Add producer warning for spurious DID messages

A producer may be in the midst of linking a consumer when a request
to link arrives from another consumer. The request is a DID string
and we can implicitly ignore it when JSON.parse throws and
display a warning instead.

* Add producer check for stored username

* Add received message while delegating warning

* Add producer linking preflight

We want to check that the producer can actually delegate the account
before broadcast. The producer can delegate if it has the original
keypair the account was created with or if it has a UCAN that delegates
it SUPER_USER capabilities. In the first case, we check that the public
key matches the DID in DNS. In the second case, we check the root issuer
matches the DID in DNS and capabilities are granted in the UCAN.

* Add websocket data type

* Dependency inject checkCapability

The checkCapability functions checks whether a producer can delegate an
account given a username. The semantics of "can delegate" will depend on
the implementations of the delegateAccount and linkDevice functions.
Both delegateAccount and linkDevice are dependency injected, and making
checkCapability dependency injectable lets implementers align all three
functions.

* Add username param to delegateAccount

We currently set the username internally in storage at webnative.auth_username,
but developers want to use username in other ways when linking a
device. We pass the username into delegateAccount, which can then
forward it to linkDevice during the linking process.

* Print linking warnings when debug enabled

* Convert confirm and reject pin to call delegation

Returning a function from delegateAccount and declineDelegation was
problematic because confirm or reject pin would call the returned
functions multiple times. Wrapping the call internally works better
and will be easier to test.

* Add tryParseMessage guard

We want to guard message when they expect JSON with a specific shape,
but instead receive a string (a temporary DID) or a message that doesn't
have the shape they expect. These cases warn because they are likely
noise on the channel.
A few test cases updated and fixed here as well.

* Add more integration tests

* Re-export account linking functions from top level

The consumer closed in prematuraly in warning cases. Moved its done call
to when and if it has successfully linked.

* Add linkDevice unit tests

* Add delegateAccount and declineDelegation unit tests

* Rename and export provider and requestor types

* Add auth lobby DI implementation

* Add docstrings to external calls

* Mark multiple consumer test as skipped

We want to keep this test around for when we implement account linking
message queues, but it is inconsistent and we should skip it for now.

* Update changelog

* Update version

* Rename WebSocketData to ChannelData

* Add once guard to confirm and reject pin

We only want these functions to be called once and only one or the other
should be called.

* Rename createChannel to createWssChannel

* Decrease number of filesystem API test runs

* Move LinkingStep type to common linking module

* Fix account registration exports

Destructuring the implemenation and exporting its functions does not
work because it only happens when the module evaluates. Instead, we can
export wrapped versions of the implementation functions.

* Rename provider and requestor

Rename provider to producer and requestor to consumer. Export them at
the top level under account instead of auth.

* Convert linking steps to an enum

* Remove unused temporaryRsaPair from producer

* Use event maps (typed EventEmitter)

* Convert event emitter to node style interface

Co-authored-by: Brian Ginsburg <gins@brianginsburg.com>
Co-authored-by: Philipp Krüger <philipp.krueger1@gmail.com>
  • Loading branch information
3 people committed Mar 17, 2022
1 parent 3244b47 commit d69fce9
Show file tree
Hide file tree
Showing 21 changed files with 1,821 additions and 10 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Changelog


### v0.32.0

- Adds app owned account linking

### v0.31.1

Move `madge` and `typedoc-plugin-missing-exports` from `dependencies` into `devDependencies`.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "webnative",
"version": "0.31.1",
"version": "0.32.0",
"description": "Fission Webnative SDK",
"keywords": [
"WebCrypto",
Expand Down
72 changes: 72 additions & 0 deletions src/auth/channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import * as did from "../did/index.js"
import { setup } from "../setup/internal.js"
import { LinkingError } from "./linking.js"

import type { Maybe } from "../common/index.js"

export type Channel = {
send: (data: ChannelData) => void
close: () => void
}

export type ChannelOptions = {
username: string
handleMessage: (event: MessageEvent) => void
}

export type ChannelData = string | ArrayBufferLike | Blob | ArrayBufferView

export const createWssChannel = async (options: ChannelOptions): Promise<Channel> => {
const { username, handleMessage } = options

const rootDid = await await did.root(username).catch(() => null)
if (!rootDid) {
throw new LinkingError(`Failed to lookup DID for ${username}`)
}

const apiEndpoint = setup.getApiEndpoint()
const endpoint = apiEndpoint.replace(/^https?:\/\//, "wss://")
const topic = `deviceLink#${rootDid}`
console.log("Opening channel", topic)

const socket: Maybe<WebSocket> = new WebSocket(`${endpoint}/user/link/${rootDid}`)
await waitForOpenConnection(socket)
socket.onmessage = handleMessage

const send = publishOnWssChannel(socket)
const close = closeWssChannel(socket)

return {
send,
close
}
}

const waitForOpenConnection = async (socket: WebSocket): Promise<void> => {
return new Promise((resolve, reject) => {
socket.onopen = () => {
resolve()
}
socket.onerror = () => {
reject("Websocket channel could not be opened")
}
})
}

export const closeWssChannel = (socket: Maybe<WebSocket>): () => void => {
return function () {
if (socket) {
socket.close(1000)
}
}
}

export const publishOnWssChannel = (socket: WebSocket): (data: ChannelData) => void => {
return function (data: ChannelData) {
const binary = typeof data === "string"
? new TextEncoder().encode(data).buffer
: data

socket?.send(binary)
}
}
5 changes: 5 additions & 0 deletions src/auth/implementation/types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { InitOptions } from "../../init/types.js"
import { State } from "../state.js"

import type { Channel, ChannelOptions } from "../../auth/channel"

export type Implementation = {
init: (options: InitOptions) => Promise<State | null>
register: (options: { email: string; username: string }) => Promise<{ success: boolean }>
isUsernameValid: (username: string) => Promise<boolean>
isUsernameAvailable: (username: string) => Promise<boolean>
createChannel: (options: ChannelOptions) => Promise<Channel>
checkCapability: (username: string) => Promise<boolean>
delegateAccount: (username: string, audience: string) => Promise<Record<string, unknown>>
linkDevice: (data: Record<string, unknown>) => Promise<void>
}
16 changes: 16 additions & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { impl } from "./implementation.js"

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

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

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

export { AccountLinkingProducer, createProducer } from "./linking/producer.js"
export { AccountLinkingConsumer, createConsumer } from "./linking/consumer.js"
21 changes: 20 additions & 1 deletion src/auth/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { InitOptions } from "../init/types.js"
import { State } from "./state.js"


import type { Channel, ChannelOptions } from "./channel"

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

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

Expand All @@ -18,3 +20,20 @@ export const isUsernameValid = (username: string): Promise<boolean> => {
export const isUsernameAvailable = (username: string): Promise<boolean> => {
return authLobby.isUsernameAvailable(username)
}


export const createChannel = (options: ChannelOptions): Promise<Channel> => {
return authLobby.createChannel(options)
}

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

export const delegateAccount = (username: string, audience: string): Promise<Record<string, unknown>> => {
return authLobby.delegateAccount(username, audience)
}

export const linkDevice = (data: Record<string, unknown>): Promise<void> => {
return authLobby.linkDevice(data)
}
66 changes: 66 additions & 0 deletions src/auth/linking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { Result } from "../common/index.js"

import * as debug from "../common/debug.js"


export enum LinkingStep {
Broadcast = "BROADCAST",
Negotiation = "NEGOTIATION",
Delegation = "DELEGATION"
}

export class LinkingError extends Error {
constructor(message: string) {
super(message)
this.name = "LinkingError"
}
}

export class LinkingWarning extends Error {
constructor(message: string) {
super(message)
this.name = "LinkingWarning"
}
}

export const handleLinkingError = (error: LinkingError | LinkingWarning): void => {
switch (error.name) {
case "LinkingWarning":
debug.warn(error.message)
break

case "LinkingError":
throw error

default:
throw error
}
}

export const tryParseMessage = <T>(
data: string,
typeGuard: (message: unknown) => message is T,
context: { participant: string; callSite: string }
): Result<T, LinkingWarning> => {
try {
const message = JSON.parse(data)

if (typeGuard(message)) {
return {
ok: true,
value: message
}
} else {
return {
ok: false,
error: new LinkingWarning(`${context.participant} received an unexpected message in ${context.callSite}: ${data}. Ignoring message.`)
}
}

} catch {
return {
ok: false,
error: new LinkingWarning(`${context.participant} received a message in ${context.callSite} that it could not parse: ${data}. Ignoring message.`)
}
}
}
Loading

0 comments on commit d69fce9

Please sign in to comment.