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(web): implement Passkey authentication #109

Merged
merged 12 commits into from
Feb 2, 2025
132 changes: 132 additions & 0 deletions apps/web/app/api/passkey/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Usage

## API Endpoints

- **Generate Registration Options:**

`POST /api/passkey/generate-registration-options`

- Request Body: `{ "identifier": "[email protected]" }`
- Success Response (200):
```json
{
// WebAuthn registration options
}
```
- Error Response (500):
```json
{
"error": "No matching RP ID found for the given origin"
}
```

- **Verify Registration:**

`POST /api/passkey/verify-registration`

- Request Body: `{ "identifier": "[email protected]", "registrationResponse": {...} }`
- Success Response (200):
```json
{
// Verification result
}
```
- Error Response (500):
```json
{
"error": "Challenge not found"
}
```

- **Generate Authentication Options:**

`POST /api/passkey/generate-authentication-options`

- Request Body: `{ "identifier": "[email protected]" }`
- Success Response (200):
```json
{
// WebAuthn authentication options
}
```
- Error Response (500):
```json
{
"error": "Authenticator not registered"
}
```

- **Verify Authentication:**

`POST /api/passkey/verify-authentication`

- Request Body: `{ "identifier": "[email protected]", "authenticationResponse": {...} }`
- Success Response (200):
```json
{
// Verification result
}
```
- Error Response (500):
```json
{
"error": "Challenge not found"
}
```

### Common Error Messages

- "No matching RP ID found for the given origin"
- "Challenge not found"
- "Authenticator not registered"
- "An unexpected error occurred"

## Flow

### Registration Flow (Sequence Diagram)

```mermaid
sequenceDiagram
participant C as Client
participant R as Registration API
participant P as Passkey Service
participant D as Redis

C->>R: POST /generate-registration-options
R->>P: Parse request & extract `identifier` and `origin`
P->>D: Save challenge for user
D-->>P: Challenge saved with TTL
P-->>R: Return registration options (or error)
R-->>C: JSON response with registration options
```

Summary:

1. **Request Registration Options**: The user device initiates the registration process by requesting options from the API.
2. **Return WebAuthn Registration Options**: The API responds with the necessary WebAuthn registration options.
3. **Create Registration Response**: The user device uses these options to create a registration response.
4. **Verify Registration**: The API verifies the registration response and returns the result to the user device.

### Authentication Flow (Sequence Diagram)

```mermaid
sequenceDiagram
participant C as Client
participant V as Verification API
participant P as Passkey Service
participant D as Redis

C->>V: POST /verify-registration or /verify-authentication
V->>P: Parse request & construct input
P->>D: Retrieve stored challenge
D-->>P: Challenge data (or not found)
P-->>V: Return verification result (or error)
V-->>C: JSON response with verification outcome
```

Summary:

1. **Request Authentication Options**: The user device requests authentication options from the API.
2. **Return WebAuthn Authentication Options**: The API provides the necessary WebAuthn authentication options.
3. **Create Authentication Response**: The user device creates an authentication response using the provided options.
4. **Verify Authentication**: The API verifies the authentication response and returns the result to the user device.
24 changes: 24 additions & 0 deletions apps/web/app/api/passkey/generate-authentication-options/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { type NextRequest, NextResponse } from 'next/server'
import { InAppError } from '~/lib/passkey/errors'
import { getAuthenticationOptions } from '~/lib/passkey/passkey'

export async function POST(req: NextRequest) {
try {
const body = await req.json()
const input = {
identifier: body.identifier,
origin: req.headers.get('origin') || body.origin || '',
challenge: body.challenge,
}
const options = await getAuthenticationOptions(input)
return NextResponse.json(options)
} catch (error) {
if (error instanceof InAppError) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json(
{ error: 'Failed to generate authentication options' },
{ status: 500 },
)
}
}
23 changes: 23 additions & 0 deletions apps/web/app/api/passkey/generate-registration-options/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { type NextRequest, NextResponse } from 'next/server'
import { InAppError } from '~/lib/passkey/errors'
import { getRegistrationOptions } from '~/lib/passkey/passkey'

export async function POST(req: NextRequest) {
try {
const body = await req.json()
const input = {
identifier: body.identifier,
origin: req.headers.get('origin') || body.origin || '',
}
const options = await getRegistrationOptions(input)
return NextResponse.json(options)
} catch (error) {
if (error instanceof InAppError) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json(
{ error: 'Failed to generate registration options' },
{ status: 500 },
)
}
}
25 changes: 25 additions & 0 deletions apps/web/app/api/passkey/verify-authentication/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { type NextRequest, NextResponse } from 'next/server'
import { InAppError } from '~/lib/passkey/errors'
import { verifyAuthentication } from '~/lib/passkey/passkey'

export async function POST(req: NextRequest) {
try {
const body = await req.json()
const input = {
identifier: body.identifier,
origin: req.headers.get('origin') || body.origin || '',
authenticationResponse: body.authenticationResponse,
}
const options = await verifyAuthentication(input)
return NextResponse.json(options)
} catch (error) {
console.error('Error verifying authentication', error)
if (error instanceof InAppError) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json(
{ error: 'Failed to verify authentication' },
{ status: 500 },
)
}
}
25 changes: 25 additions & 0 deletions apps/web/app/api/passkey/verify-registration/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { type NextRequest, NextResponse } from 'next/server'
import { InAppError } from '~/lib/passkey/errors'
import { verifyRegistration } from '~/lib/passkey/passkey'

export async function POST(req: NextRequest) {
try {
const body = await req.json()
const input = {
identifier: body.identifier,
origin: req.headers.get('origin') || body.origin || '',
registrationResponse: body.registrationResponse,
}
const options = await verifyRegistration(input)
return NextResponse.json(options)
} catch (error) {
if (error instanceof InAppError) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
console.error('Error verifying registration', error)
return NextResponse.json(
{ error: 'Failed to verify registration' },
{ status: 500 },
)
}
}
93 changes: 93 additions & 0 deletions apps/web/lib/passkey/deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {
Account,
Address,
type Keypair,
Operation,
SorobanRpc,
StrKey,
TransactionBuilder,
hash,
xdr,
} from '@stellar/stellar-sdk'
import { ENV } from '~/lib/passkey/env'

const { RPC_URL, FACTORY_CONTRACT_ID, HORIZON_URL, NETWORK_PASSPHRASE } = ENV

export async function handleDeploy(
bundlerKey: Keypair,
contractSalt: Buffer,
publicKey?: Buffer,
) {
const rpc = new SorobanRpc.Server(RPC_URL)
const deployee = StrKey.encodeContract(
hash(
xdr.HashIdPreimage.envelopeTypeContractId(
new xdr.HashIdPreimageContractId({
networkId: hash(Buffer.from(NETWORK_PASSPHRASE, 'utf-8')),
contractIdPreimage:
xdr.ContractIdPreimage.contractIdPreimageFromAddress(
new xdr.ContractIdPreimageFromAddress({
address: Address.fromString(FACTORY_CONTRACT_ID).toScAddress(),
salt: contractSalt,
}),
),
}),
).toXDR(),
),
)

// This is a signup deploy vs a signin deploy. Look up if this contract has been already been deployed, otherwise fail
if (!publicKey) {
await rpc.getContractData(
deployee,
xdr.ScVal.scvLedgerKeyContractInstance(),
)
return deployee
}

const bundlerKeyAccount = await rpc
.getAccount(bundlerKey.publicKey())
.then((res) => new Account(res.accountId(), res.sequenceNumber()))
const simTxn = new TransactionBuilder(bundlerKeyAccount, {
fee: '100',
networkPassphrase: NETWORK_PASSPHRASE,
})
.addOperation(
Operation.invokeContractFunction({
contract: FACTORY_CONTRACT_ID,
function: 'deploy',
args: [xdr.ScVal.scvBytes(contractSalt), xdr.ScVal.scvBytes(publicKey)],
}),
)
.setTimeout(0)
.build()

const sim = await rpc.simulateTransaction(simTxn)

if (
SorobanRpc.Api.isSimulationError(sim) ||
SorobanRpc.Api.isSimulationRestore(sim)
)
throw sim

const transaction = SorobanRpc.assembleTransaction(simTxn, sim)
.setTimeout(0)
.build()

transaction.sign(bundlerKey)

// TODO failure here is resulting in sp:deployee undefined
// TODO handle archived entries

const txResp = await (
await fetch(`${HORIZON_URL}/transactions`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ tx: transaction.toXDR() }),
})
).json()

if (txResp.successful) return deployee

throw txResp
}
Loading
Loading