Skip to content

Commit

Permalink
feat: support openid invitations and deeplinking improvements (#97)
Browse files Browse the repository at this point in the history
Signed-off-by: Timo Glastra <timo@animo.id>
  • Loading branch information
TimoGlastra authored Apr 9, 2024
1 parent 58989b4 commit 347d7b0
Show file tree
Hide file tree
Showing 27 changed files with 666 additions and 283 deletions.
3 changes: 2 additions & 1 deletion apps/expo/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ const invitationSchemes = [
'openid-initiate-issuance',
'openid-credential-offer',
'openid-vc',
'openid4vp',
'didcomm',
]

const associatedDomains = ['paradym.id', 'dev.paradym.id']
const associatedDomains = ['paradym.id', 'dev.paradym.id', 'aurora.paradym.id']

/**
* @type {import('@expo/config-types').ExpoConfig}
Expand Down
16 changes: 16 additions & 0 deletions apps/expo/app/[...unmatched].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { usePathname, useGlobalSearchParams } from 'expo-router'

// NOTE: for all unmatched routes we render null, as it's good chance that
// we got here due to deep-linking, and we already handle that somewhere else
export default () => {
const pathname = usePathname()
const searchParams = useGlobalSearchParams()

// eslint-disable-next-line no-console
console.warn(
'Landed on unmatched route (probably due to deeplinking in which case this is not an error)',
{ pathname, searchParams }
)

return null
}
23 changes: 7 additions & 16 deletions apps/expo/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ import { getSecureWalletKey } from '../utils/walletKeyStore'

void SplashScreen.preventAutoHideAsync()

export const unstable_settings = {
// Ensure any route can link back to `/`
initialRouteName: 'index',
}

export default function HomeLayout() {
const [fontLoaded] = useFonts({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
Expand Down Expand Up @@ -144,17 +149,7 @@ export default function HomeLayout() {
<ThemeProvider value={DefaultTheme}>
<NoInternetToastProvider>
<DeeplinkHandler>
<Stack screenOptions={{ headerShown: false }}>
{/**
* Workaround:
* The following screens are not rendered by the router.
* They are used to prevent the internal route to be executed.
* So now they are being redirected to the home screen. So the user will not see a 404.
**/}
<Stack.Screen name="invitation/[id]" redirect />
<Stack.Screen name="https/[...dummy]" redirect />
<Stack.Screen name="http/[...dummy]" redirect />

<Stack initialRouteName="index" screenOptions={{ headerShown: false }}>
<Stack.Screen
options={{
presentation: 'modal',
Expand All @@ -166,17 +161,13 @@ export default function HomeLayout() {
options={{ presentation: 'modal', ...headerModalOptions }}
name="notifications/openIdCredential"
/>
<Stack.Screen
options={{ presentation: 'modal', ...headerModalOptions }}
name="notifications/didCommCredential"
/>
<Stack.Screen
options={{ presentation: 'modal', ...headerModalOptions }}
name="notifications/openIdPresentation"
/>
<Stack.Screen
options={{ presentation: 'modal', ...headerModalOptions }}
name="notifications/didCommPresentation"
name="notifications/didcomm"
/>
<Stack.Screen
options={{
Expand Down
5 changes: 0 additions & 5 deletions apps/expo/app/http/[...dummy].tsx

This file was deleted.

5 changes: 0 additions & 5 deletions apps/expo/app/https/[...dummy].tsx

This file was deleted.

5 changes: 0 additions & 5 deletions apps/expo/app/invitation/[id].tsx

This file was deleted.

9 changes: 0 additions & 9 deletions apps/expo/app/notifications/didCommCredential.tsx

This file was deleted.

9 changes: 0 additions & 9 deletions apps/expo/app/notifications/didCommPresentation.tsx

This file was deleted.

9 changes: 9 additions & 0 deletions apps/expo/app/notifications/didcomm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { DidCommNotificationScreen } from 'app/features/notifications'

export default function Screen() {
return (
<>
<DidCommNotificationScreen />
</>
)
}
2 changes: 1 addition & 1 deletion apps/expo/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "expo-app",
"version": "1.2.4",
"version": "1.3.0",
"main": "expo-router/entry",
"private": true,
"scripts": {
Expand Down
55 changes: 45 additions & 10 deletions apps/expo/utils/DeeplinkHandler.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,65 @@
import type { ReactNode } from 'react'

import { QrTypes } from '@internal/agent'
import { InvitationQrTypes } from '@internal/agent'
import { useToastController } from '@internal/ui'
import { CommonActions } from '@react-navigation/native'
import { useCredentialDataHandler } from 'app/hooks/useCredentialDataHandler'
import * as Linking from 'expo-linking'
import { useNavigation } from 'expo-router'
import { useEffect, useState } from 'react'

interface DeeplinkHandlerProps {
children: ReactNode
}

const deeplinkSchemes = Object.values(QrTypes)
const deeplinkSchemes = Object.values(InvitationQrTypes)

export const DeeplinkHandler = ({ children }: DeeplinkHandlerProps) => {
const url = Linking.useURL()
const [lastDeeplink, setLastDeeplink] = useState<string | null>(null)
const { handleCredentialData } = useCredentialDataHandler()
const toast = useToastController()
const navigation = useNavigation()

useEffect(() => {
if (!url || url === lastDeeplink) return
// TODO: I'm not sure if we need this? Or whether an useEffect without any deps is enough?
const [hasHandledInitialUrl, setHasHandledInitialUrl] = useState(false)

function handleUrl(url: string) {
const isRecognizedDeeplink = deeplinkSchemes.some((scheme) => url.startsWith(scheme))

// Whenever a deeplink comes in, we reset the state. This is due to expo
// routing us always and we can't intercept that. It seems they are working on
// more control, but for now this is the cleanest approach
navigation.dispatch(
CommonActions.reset({
routes: [{ key: 'index', name: 'index' }],
})
)

// Ignore deeplinks that don't start with the schemes for credentials
if (!deeplinkSchemes.some((scheme) => url.startsWith(scheme))) return
if (isRecognizedDeeplink) {
void handleCredentialData(url).then((result) => {
if (!result.success) {
toast.show(result.error)
}
})
}
}

setLastDeeplink(url)
void handleCredentialData(url)
}, [url])
// NOTE: we use getInitialURL and the event listener over useURL as we don't know
// using that method whether the same url is opened multiple times. As we need to make
// sure to handle ALL incoming deeplinks (to prevent default expo-router behaviour) we
// handle them ourselves. On startup getInitialUrl will be called once.
useEffect(() => {
if (hasHandledInitialUrl) return
void Linking.getInitialURL().then((url) => {
if (url) handleUrl(url)
setHasHandledInitialUrl(true)
})
}, [hasHandledInitialUrl])

useEffect(() => {
const eventListener = Linking.addEventListener('url', (event) => handleUrl(event.url))
return () => eventListener.remove()
}, [])

return <>{children}</>
}
4 changes: 2 additions & 2 deletions packages/agent/src/hooks/useInboxNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export const useInboxNotifications = () => {
createdAt: record.createdAt,
contactLabel: metadata?.issuerName,
notificationTitle: metadata?.credentialName ?? 'Credential',
}
} as const
} else {
const metadata = getDidCommProofExchangeDisplayMetadata(record)

Expand All @@ -138,7 +138,7 @@ export const useInboxNotifications = () => {
createdAt: record.createdAt,
contactLabel: metadata?.verifierName,
notificationTitle: metadata?.proofName ?? 'Data Request',
}
} as const
}
})
}, [proofExchangeRecords, credentialExchangeRecords])
Expand Down
2 changes: 1 addition & 1 deletion packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ global.Buffer = Buffer

export { initializeAgent, useAgent, AppAgent } from './agent'
export * from './providers'
export * from './parsers'
export * from './invitation'
export * from './display'
export * from './hooks'
export {
Expand Down
97 changes: 97 additions & 0 deletions packages/agent/src/invitation/fetchInvitation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { ParseInvitationResult } from './parsers'

const errorResponse = (message: string) => {
return {
success: false,
error: message,
} as const
}

export async function fetchInvitationDataUrl(dataUrl: string): Promise<ParseInvitationResult> {
// If we haven't had a response after 10 seconds, we will handle as if the invitation is not valid.
const abortController = new AbortController()
const timeout = setTimeout(() => abortController.abort('timeout reached'), 10000)

try {
// If we still don't know what type of invitation it is, we assume it is a URL that we need to fetch to retrieve the invitation.
const response = await fetch(dataUrl, {
headers: {
// for DIDComm out of band invitations we should include application/json
// but we are flexible and also want to support other types of invitations
// as e.g. the OpenID SIOP request is a signed encoded JWT string
Accept: 'application/json, text/plain, */*',
},
})
clearTimeout(timeout)
if (!response.ok) {
return errorResponse('Unable to retrieve invitation.')
}

const contentType = response.headers.get('content-type')
if (contentType?.includes('application/json')) {
const json: unknown = await response.json()
return handleJsonResponse(json)
} else {
const text = await response.text()
return handleTextResponse(text)
}
} catch (error) {
clearTimeout(timeout)
return errorResponse('Unable to retrieve invitation.')
}
}

function handleJsonResponse(json: unknown): ParseInvitationResult {
// We expect a JSON object
if (!json || typeof json !== 'object' || Array.isArray(json)) {
return errorResponse('Invitation not recognized.')
}

if ('@type' in json) {
return {
success: true,
result: {
format: 'parsed',
type: 'didcomm',
data: json,
},
}
}

if ('credential_issuer' in json) {
return {
success: true,
result: {
format: 'parsed',
type: 'openid-credential-offer',
data: json,
},
}
}

return errorResponse('Invitation not recognized.')
}

function handleTextResponse(text: string): ParseInvitationResult {
// If the text starts with 'ey' we assume it's a JWT and thus an OpenID authorization request
if (text.startsWith('ey')) {
return {
success: true,
result: {
format: 'parsed',
type: 'openid-authorization-request',
data: text,
},
}
}

// Otherwise we still try to parse it as JSON
try {
const json: unknown = JSON.parse(text)
return handleJsonResponse(json)

// handel like above
} catch (error) {
return errorResponse('Invitation not recognized.')
}
}
Loading

0 comments on commit 347d7b0

Please sign in to comment.