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

feat: support openid invitations and deeplinking improvements #97

Merged
merged 5 commits into from
Apr 9, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
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 () => {
Copy link
Member

Choose a reason for hiding this comment

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

Quite a nicer workaround 👍

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 />
</>
)
}
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
Loading