True passkey implement
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -136,3 +136,4 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
.DS_Store
|
||||
|
||||
195
PASSKEY.md
Normal file
195
PASSKEY.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# MCPletA2A Passkey 認証
|
||||
|
||||
真正の WebAuthn/FIDO2 Passkey 認証。外部 amiPro FIDO2 サーバーと連携。
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
```
|
||||
Host (Node.js)
|
||||
│
|
||||
├── PasskeyServer ──→ ブラウザページ起動(自己完結型フロー)
|
||||
│ │
|
||||
│ ├── userId 入力
|
||||
│ ├── FIDO2 サーバーにユーザー確認
|
||||
│ ├── 未登録 → 自動で Passkey 登録(Touch ID 等)
|
||||
│ ├── 認証(Touch ID 等)
|
||||
│ └── 結果を Host に HTTP callback
|
||||
│
|
||||
├── PasskeyPlatformService ──→ AmiProFIDO2Client ──→ FIDO2 Server
|
||||
│ (統一管理: 登録/認証/セッション) (https://fido2.epk.amipro.me)
|
||||
│
|
||||
└── PasskeyAPIServer (REST proxy, port 8443)
|
||||
/api/passkey/attestation/options
|
||||
/api/passkey/attestation/result
|
||||
/api/passkey/assertion/options
|
||||
/api/passkey/assertion/result
|
||||
```
|
||||
|
||||
## ファイル構成
|
||||
|
||||
```
|
||||
platform_impl/src/passkey/
|
||||
├── amipro-fido2-client.ts # amiPro FIDO2 サーバー REST クライアント
|
||||
├── platform-service.ts # 統一 Passkey 管理サービス
|
||||
├── passkey-server.ts # ブラウザ WebAuthn 式典サーバー(自己完結型ページ)
|
||||
├── api-server.ts # REST API プロキシ
|
||||
├── index.ts # エクスポート
|
||||
├── storage.ts # IPasskeyStorage インターフェース (ローカル用、予備)
|
||||
├── challenge-manager.ts # チャレンジ管理 (ローカル用、予備)
|
||||
├── fido2-backend.ts # FIDO2 検証 (ローカル用、予備)
|
||||
├── client.ts # ブラウザ側 WebAuthn クライアント
|
||||
└── mcplet-helper.ts # MCPlet ツール統合ヘルパー
|
||||
```
|
||||
|
||||
## 設定
|
||||
|
||||
```yaml
|
||||
# reference_impl/config/reference.yaml
|
||||
passkey:
|
||||
mode: https # localhost | https
|
||||
rpId: a2a-demo.mcplet.ai # WebAuthn Relying Party ID
|
||||
fido2ServerUrl: https://fido2.epk.amipro.me # 外部 FIDO2 サーバー
|
||||
apiPort: 8443 # REST API ポート
|
||||
```
|
||||
|
||||
## ユーザーフロー
|
||||
|
||||
### 新規ユーザー(未登録)
|
||||
|
||||
```
|
||||
ブラウザページ起動
|
||||
↓
|
||||
1. userId 入力(または自動入力)
|
||||
↓
|
||||
2. FIDO2 サーバーに問い合わせ → "ユーザーが存在しません"
|
||||
↓
|
||||
3. 自動で登録モードに切替 → Touch ID 等で Passkey 登録
|
||||
↓
|
||||
4. 登録成功 → 自動で認証に進む
|
||||
↓
|
||||
5. Touch ID 等で認証
|
||||
↓
|
||||
6. session + username を Host に返却
|
||||
```
|
||||
|
||||
### 既存ユーザー(登録済み)
|
||||
|
||||
```
|
||||
ブラウザページ起動
|
||||
↓
|
||||
1. userId 入力(または自動入力)
|
||||
↓
|
||||
2. FIDO2 サーバーに問い合わせ → OK(allowCredentials 返却)
|
||||
↓
|
||||
3. Touch ID 等で認証
|
||||
↓
|
||||
4. session + username を Host に返却
|
||||
```
|
||||
|
||||
## amiPro FIDO2 Server API
|
||||
|
||||
| エンドポイント | 説明 |
|
||||
|--------------|------|
|
||||
| `POST /attestation/options` | 登録オプション取得(challenge + publicKey params) |
|
||||
| `POST /attestation/result` | 登録結果送信(attestationObject 検証) |
|
||||
| `POST /assertion/options` | 認証オプション取得(challenge + allowCredentials) |
|
||||
| `POST /assertion/result` | 認証結果送信(signature 検証) |
|
||||
| `POST /usr/validsession` | セッション検証 |
|
||||
| `POST /usr/delsession` | ログアウト |
|
||||
|
||||
エンコーディング: Base64URL(dfido2-lib.js 互換)
|
||||
|
||||
## 開発環境セットアップ
|
||||
|
||||
### 1. /etc/hosts
|
||||
|
||||
```
|
||||
127.0.0.1 a2a-demo.mcplet.ai
|
||||
```
|
||||
|
||||
### 2. 自己署名証明書の生成
|
||||
|
||||
```bash
|
||||
cd platform_impl
|
||||
mkdir -p certs
|
||||
openssl req -x509 -newkey rsa:2048 \
|
||||
-keyout certs/key.pem -out certs/cert.pem \
|
||||
-days 365 -nodes -subj "/CN=a2a-demo.mcplet.ai" \
|
||||
-addext "subjectAltName=DNS:a2a-demo.mcplet.ai,IP:127.0.0.1"
|
||||
```
|
||||
|
||||
### 3. 証明書の信頼(macOS)
|
||||
|
||||
```bash
|
||||
# Admin 権限あり
|
||||
sudo security add-trusted-cert -d -r trustRoot \
|
||||
-k /Library/Keychains/System.keychain certs/cert.pem
|
||||
|
||||
# Admin 権限なし(Edge で有効、Chrome は完全再起動が必要)
|
||||
security add-trusted-cert -r trustRoot \
|
||||
-k ~/Library/Keychains/login.keychain-db certs/cert.pem
|
||||
```
|
||||
|
||||
### 4. テスト
|
||||
|
||||
```bash
|
||||
cd platform_impl
|
||||
|
||||
# API 接続テストのみ
|
||||
npx tsx test-passkey.ts
|
||||
|
||||
# 統一フロー(自動登録+認証)
|
||||
npx tsx test-passkey.ts test a2a-test-user
|
||||
|
||||
# 新規ユーザー(userId 手入力)
|
||||
npx tsx test-passkey.ts new
|
||||
```
|
||||
|
||||
## 重要な注意事項
|
||||
|
||||
### WebAuthn rpId とドメインの一致
|
||||
|
||||
WebAuthn は rpId がブラウザページのドメインと一致することを要求する。
|
||||
`http://127.0.0.1` のページでは rpId `a2a-demo.mcplet.ai` は使用不可。
|
||||
必ず /etc/hosts + HTTPS(または Chrome flag)で一致させること。
|
||||
|
||||
### Passkey のブラウザ間共有
|
||||
|
||||
macOS の Touch ID / iCloud キーチェーンで作成された Passkey はブラウザ間で共有される。
|
||||
ただし Chrome の `--user-data-dir=tmp` で起動すると隔離プロファイルとなり、
|
||||
プラットフォーム Passkey にアクセスできない。常にデフォルトブラウザを使用すること。
|
||||
|
||||
### 本番環境 vs 開発環境
|
||||
|
||||
| 項目 | 開発環境 | 本番環境 |
|
||||
|------|---------|---------|
|
||||
| 証明書 | 自己署名 + キーチェーン信頼 | 正規 SSL 証明書 |
|
||||
| rpId | a2a-demo.mcplet.ai (hosts) | 実ドメイン (DNS) |
|
||||
| FIDO2 server | fido2.epk.amipro.me | 同 or 専用サーバー |
|
||||
| `PASSKEY_DEV_HTTP` | 設定可(Chrome flag 必要) | 不使用 |
|
||||
|
||||
## Platform 統合
|
||||
|
||||
### base-agent.ts での Passkey 呼び出し
|
||||
|
||||
```typescript
|
||||
// action 型ツールで auth.required === 'passkey' の場合、自動で仪式を開始
|
||||
if (tool.meta.mcpletType === 'action' && tool.meta.auth?.required === 'passkey') {
|
||||
const assertion = await this.performPasskeyCeremony(tool);
|
||||
if (!assertion) {
|
||||
return this.errorResult(toolName, 'Passkey authentication failed', 'AUTH_FAILED');
|
||||
}
|
||||
callParams = { ...args, _mcplet_auth: assertion };
|
||||
}
|
||||
```
|
||||
|
||||
### MCPletHost 初期化
|
||||
|
||||
```typescript
|
||||
if (config.passkey) {
|
||||
this.passkeyPlatformService = new PasskeyPlatformService(rpId, fido2ServerUrl);
|
||||
this.passkeyAPIServer = new PasskeyAPIServer(platformService, rpId, origin);
|
||||
this.passkeyAPIServer.start(config.passkey.apiPort || 8443);
|
||||
this.passkeyServer = new PasskeyServer(rpId, fido2ServerUrl);
|
||||
}
|
||||
```
|
||||
20
platform_impl/certs/cert.pem
Normal file
20
platform_impl/certs/cert.pem
Normal file
@@ -0,0 +1,20 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDTTCCAjWgAwIBAgIUJhMaUllayBlEWhHMSqGee7bgnS0wDQYJKoZIhvcNAQEL
|
||||
BQAwHTEbMBkGA1UEAwwSYTJhLWRlbW8ubWNwbGV0LmFpMB4XDTI2MDMzMTAwMDMy
|
||||
NFoXDTI3MDMzMTAwMDMyNFowHTEbMBkGA1UEAwwSYTJhLWRlbW8ubWNwbGV0LmFp
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1SW40G66aT4eU4Gg0hCD
|
||||
BrGWiYPiqmWpEbhbReYaONjn7Fr70YDuPWQjWA4xF3FP7gZVLpJyfhkA5kllKwNG
|
||||
ngLpQWTJ2Fd++yoWxPu+pMXaaQwXT74RF+G5hx3+NNBj06bwwzlg9EbqeQhn2k/S
|
||||
GZrRyQLGiQB1A8mWnF63EwEvRgZKgLmvA+LngzRxIhwwK5yqqdgT2NRV90aB5mVM
|
||||
92/BqV8Gp6hdemPII8Z6t4EzcHxdnjvnz1DQoo4gySV36vuEDxPXV2Xdg9eNDuNY
|
||||
zu2iUR2y6nZ2ED+gztu0okws4yn/vFg++PAWkz89RZex+6cJaZxnOO5Eb2RJ0+mh
|
||||
1wIDAQABo4GEMIGBMB0GA1UdDgQWBBSKllweg2yz5okdZyZ4b7q2AG1RojAfBgNV
|
||||
HSMEGDAWgBSKllweg2yz5okdZyZ4b7q2AG1RojAPBgNVHRMBAf8EBTADAQH/MC4G
|
||||
A1UdEQQnMCWCEmEyYS1kZW1vLm1jcGxldC5haYIJbG9jYWxob3N0hwR/AAABMA0G
|
||||
CSqGSIb3DQEBCwUAA4IBAQCiv3kOE5eIQhrLvgXqYIefPMyN9bPcvwp97AkB1/IL
|
||||
tF7lgVnQ8pWCQoVpCZAM42ztaV+UlZR41Kc2H1BrSTwWaHPGcHXInWkLQ1zy24KA
|
||||
E2kKwPuQi0R11Hn1zRRw2wMkqGdZ9G1xNQfLRNbCFdJPro+QOpN42yMqOyYrmJZj
|
||||
GU48CkL0t0KtP02I9pISiU+SFAJDj2sG0Okw2y7JLordmEyAPOC3lnZfwwscmmjP
|
||||
Abl7D433I01DFHFCWwcnka7t7Ofm36msr90NREIM71LLSbWOkLDWJD8JgKVrci+x
|
||||
YYba808FxrWMtt2qNMklPmYAjoRLZnBGMKBQpjarqaTD
|
||||
-----END CERTIFICATE-----
|
||||
28
platform_impl/certs/key.pem
Normal file
28
platform_impl/certs/key.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDVJbjQbrppPh5T
|
||||
gaDSEIMGsZaJg+KqZakRuFtF5ho42OfsWvvRgO49ZCNYDjEXcU/uBlUuknJ+GQDm
|
||||
SWUrA0aeAulBZMnYV377KhbE+76kxdppDBdPvhEX4bmHHf400GPTpvDDOWD0Rup5
|
||||
CGfaT9IZmtHJAsaJAHUDyZacXrcTAS9GBkqAua8D4ueDNHEiHDArnKqp2BPY1FX3
|
||||
RoHmZUz3b8GpXwanqF16Y8gjxnq3gTNwfF2eO+fPUNCijiDJJXfq+4QPE9dXZd2D
|
||||
140O41jO7aJRHbLqdnYQP6DO27SiTCzjKf+8WD748BaTPz1Fl7H7pwlpnGc47kRv
|
||||
ZEnT6aHXAgMBAAECggEAAaQLMquPiszcHedzDfrrj0shrghSX95teUHyjeyyCfr9
|
||||
eg/PPXMhIl7ZeM2PKi+Innv0/ulIsVjO7XbmLPkW+5NpKUQ125D83MEbsMOBWbJe
|
||||
No3NxiLf7c+ihnxAHzb1dcUkuFQCP48mMe1TI1aW9vR+pe38CkTzIabVHnoKLb7C
|
||||
fgq3Nbzo1sMeS5dBWgSgMK6sMHUmWcsKWzz9TnP6NFez4PuvYFO4b3QOsPppT+Ub
|
||||
pIf4u7kLP/CUPBCBEtphJNZwq5F1aH8AgSi9BbYio1FbLlR99ikM890M/jlrKCTC
|
||||
1xqNd8nK3Zr8d6r4s/43xE9zUs3M/ZNZV6eu8Y3FMQKBgQDuMksDZyR5tYuCfDI7
|
||||
D5IpllDUw9tD/b6No/b0/unQP3G3h0DRT/E9ewUjBPvDt2lEiiCPKcV9CtNNvxAV
|
||||
f4PwsYFNaY5y85BeyynF3+zUxzeQ0ohzqCExCF7mgQ3kPtblNCvNtM0SLUuP/CVU
|
||||
ppMHpCY8S9i1sowk/F0wtNMaZwKBgQDlFCIorXBhXZYIkYMg4A+g8LQtnkR1DeIz
|
||||
woRM/zANrqbnBq8m3+l7P09ZPbFzGkRQgpcKvaVeV/yI568x7Mtkm+B1Dn6NxMyd
|
||||
Vt/r/3AfVBU2cU1MHw9wtq/VT+DnMQKbu3q74Ni78YWJDJpaz8WcoHaVrU4Swtlp
|
||||
R2AaRQl3EQKBgQClDwjYNNMb2+fu5e1Q6/tXAijFJ2t79AvlzudE4phXjH9atEkA
|
||||
Qqti9SqcF8n219QEgMsLKeEGQ0glqe6VVyWw1vBJGopxscIrThGGYyOUVvB0VM/l
|
||||
hW5qsehBRtC/h6QWdE6eX1lz7RtdjVa5ECz2sJMmWVC0qCNhRde19rgKpQKBgBAF
|
||||
4H3H49xrl1ryEqHyCiXPsEqgj1lAp1nHeUmJb+sFFFeEeCvX7ZTZUMuFLSxH4g9f
|
||||
kwEFUtPOg7NvwSlUzsUywfhuExwHb+hxcygmrckDMJimRCnW4lWX8aSR+cEyBGSw
|
||||
MF2D1KUQt65mW0WO0tupvaGqhZN6XYqnm2k6+vaBAoGAH+GA7r1a5Bfjaao+WRtC
|
||||
9neuvZxS8vuiveglx9HWDtPvdBMOlPLZAgwJMueJfUqzZVbV8+oItRCy1p78DawF
|
||||
GNmgCaxCEHgY/ELjRaCgo1L6ToXZdsbpWZTongc0Osd1kCunb50KfBgaEPS8VmCz
|
||||
pYVd+Le5rIL9/XESBMrqtgY=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -170,6 +170,8 @@ export abstract class BaseAgent {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// Opens a browser page that handles the full flow:
|
||||
// userId input → auto-detect registered → register if needed → authenticate
|
||||
const assertion = await this.deps.passkeyServer.startCeremony(
|
||||
tool.meta.auth?.promptMessage ?? `Authorize action: ${tool.name}`,
|
||||
);
|
||||
|
||||
@@ -48,13 +48,14 @@ export class MCPletHost {
|
||||
// Initialize Passkey services if configured
|
||||
if (config.passkey) {
|
||||
const rpId = config.passkey.rpId || 'localhost';
|
||||
const fido2ServerUrl = config.passkey.fido2ServerUrl || 'https://fido2.epk.amipro.me';
|
||||
const origin = config.passkey.mode === 'https'
|
||||
? `https://${rpId}`
|
||||
: 'http://127.0.0.1';
|
||||
|
||||
// Create Passkey Platform Service
|
||||
this.passkeyPlatformService = new PasskeyPlatformService(rpId, origin);
|
||||
console.log(`[host] PasskeyPlatformService initialized (mode: ${config.passkey.mode})`);
|
||||
// Create Passkey Platform Service (delegates to external FIDO2 server)
|
||||
this.passkeyPlatformService = new PasskeyPlatformService(rpId, fido2ServerUrl);
|
||||
console.log(`[host] PasskeyPlatformService initialized (rpId: ${rpId}, fido2: ${fido2ServerUrl})`);
|
||||
|
||||
// Create and start Passkey API Server
|
||||
this.passkeyAPIServer = new PasskeyAPIServer(
|
||||
@@ -65,8 +66,8 @@ export class MCPletHost {
|
||||
const apiPort = config.passkey.apiPort || 8443;
|
||||
this.passkeyAPIServer.start(apiPort);
|
||||
|
||||
// For backward compatibility, also create PasskeyServer for interactive ceremonies
|
||||
this.passkeyServer = new PasskeyServer(rpId, origin);
|
||||
// PasskeyServer for interactive browser ceremonies
|
||||
this.passkeyServer = new PasskeyServer(rpId, fido2ServerUrl);
|
||||
}
|
||||
|
||||
// Connect MCPlet servers declared in config (each is a stdio child process)
|
||||
|
||||
205
platform_impl/src/passkey/amipro-fido2-client.ts
Normal file
205
platform_impl/src/passkey/amipro-fido2-client.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* AmiPro FIDO2 Server Client — Node.js HTTP Client
|
||||
*
|
||||
* Wraps REST API calls to the amiPro FIDO2 server (e.g., https://fido2.epk.amipro.me).
|
||||
* This server handles:
|
||||
* - Challenge generation
|
||||
* - Attestation/Assertion verification
|
||||
* - Session management
|
||||
* - Credential storage
|
||||
*
|
||||
* API Endpoints:
|
||||
* POST /attestation/options — Get registration options (challenge + publicKey params)
|
||||
* POST /attestation/result — Submit attestation response for verification
|
||||
* POST /assertion/options — Get authentication options (challenge + allowCredentials)
|
||||
* POST /assertion/result — Submit assertion response for verification
|
||||
* POST /usr/validsession — Validate an existing session
|
||||
* POST /usr/delsession — Logout / delete session
|
||||
* POST /usr/dvc/lst — List user devices
|
||||
* POST /usr/dvc/rm — Delete a user device
|
||||
*/
|
||||
|
||||
export interface AmiProAttestationOptions {
|
||||
status: string;
|
||||
challenge: string; // base64url-encoded
|
||||
rp: { name: string; id: string };
|
||||
user: { id: string; name: string; displayName: string };
|
||||
pubKeyCredParams: Array<{ type: string; alg: number }>;
|
||||
timeout: number;
|
||||
excludeCredentials?: Array<{ id: string; type: string }>;
|
||||
authenticatorSelection?: Record<string, unknown>;
|
||||
attestation?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface AmiProAssertionOptions {
|
||||
status: string;
|
||||
challenge: string; // base64url-encoded
|
||||
rpId: string;
|
||||
timeout: number;
|
||||
allowCredentials: Array<{ id: string; type: string; transports?: string[] }>;
|
||||
userVerification: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface AmiProResult {
|
||||
status: 'ok' | 'failed';
|
||||
session?: string;
|
||||
username?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface AmiProDeviceList {
|
||||
status: 'ok' | 'failed';
|
||||
devices?: Array<{ id: string; name?: string; createdAt?: string }>;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export class AmiProFIDO2Client {
|
||||
constructor(
|
||||
private readonly serverUrl: string, // e.g. https://fido2.epk.amipro.me
|
||||
private readonly rpId: string, // e.g. a2a-demo.mcplet.ai
|
||||
) {}
|
||||
|
||||
// ─── Registration (Attestation) ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Step 1: Request attestation options from server.
|
||||
* Server generates challenge and returns PublicKeyCredentialCreationOptions.
|
||||
*/
|
||||
async getAttestationOptions(
|
||||
username: string,
|
||||
displayName: string,
|
||||
): Promise<AmiProAttestationOptions> {
|
||||
const body = {
|
||||
username,
|
||||
displayName: encodeURIComponent(displayName),
|
||||
authenticatorSelection: {
|
||||
userVerification: 'preferred',
|
||||
requireResidentKey: false,
|
||||
},
|
||||
rp: { id: this.rpId },
|
||||
};
|
||||
|
||||
return this.post<AmiProAttestationOptions>('/attestation/options', body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3: Submit attestation result to server for verification.
|
||||
* Called after browser's navigator.credentials.create() returns.
|
||||
*/
|
||||
async submitAttestationResult(attestationResult: {
|
||||
id: string;
|
||||
rawId: string; // base64url
|
||||
type: string;
|
||||
response: {
|
||||
clientDataJSON: string; // base64url
|
||||
attestationObject: string; // base64url
|
||||
};
|
||||
transports?: string[];
|
||||
}): Promise<AmiProResult> {
|
||||
return this.post<AmiProResult>('/attestation/result', attestationResult);
|
||||
}
|
||||
|
||||
// ─── Authentication (Assertion) ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Step 1: Request assertion options from server.
|
||||
* Server generates challenge and returns allowCredentials list.
|
||||
*/
|
||||
async getAssertionOptions(
|
||||
username: string | null = null,
|
||||
): Promise<AmiProAssertionOptions> {
|
||||
const body: Record<string, unknown> = {
|
||||
username,
|
||||
authenticatorSelection: {
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
rp: { id: this.rpId },
|
||||
};
|
||||
|
||||
return this.post<AmiProAssertionOptions>('/assertion/options', body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3: Submit assertion result to server for verification.
|
||||
* Called after browser's navigator.credentials.get() returns.
|
||||
*/
|
||||
async submitAssertionResult(assertionResult: {
|
||||
id: string;
|
||||
rawId: number[] | string; // array of bytes or base64url
|
||||
type: string;
|
||||
response: {
|
||||
authenticatorData: string; // base64url
|
||||
clientDataJSON: string; // base64url
|
||||
signature: string; // base64url
|
||||
userHandle: string; // base64url
|
||||
};
|
||||
}): Promise<AmiProResult> {
|
||||
return this.post<AmiProResult>('/assertion/result', assertionResult);
|
||||
}
|
||||
|
||||
// ─── Session Management ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate an existing session.
|
||||
*/
|
||||
async validateSession(session: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.post<AmiProResult>('/usr/validsession', {
|
||||
session,
|
||||
rp: { id: this.rpId },
|
||||
});
|
||||
return result.status === 'ok';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout / delete a session.
|
||||
*/
|
||||
async deleteSession(session: string, username: string): Promise<void> {
|
||||
await this.post('/usr/delsession', { session, username });
|
||||
}
|
||||
|
||||
// ─── Device Management ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List user's registered devices/credentials.
|
||||
*/
|
||||
async listDevices(session: string): Promise<AmiProDeviceList> {
|
||||
return this.post<AmiProDeviceList>('/usr/dvc/lst', {
|
||||
session,
|
||||
rp: { id: this.rpId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user device/credential.
|
||||
*/
|
||||
async deleteDevice(session: string, deviceId: string): Promise<AmiProResult> {
|
||||
return this.post<AmiProResult>('/usr/dvc/rm', {
|
||||
session,
|
||||
device_id: deviceId,
|
||||
rp: { id: this.rpId },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── HTTP Transport ──────────────────────────────────────────────────
|
||||
|
||||
private async post<T>(path: string, body: unknown): Promise<T> {
|
||||
const url = `${this.serverUrl}${path}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`FIDO2 server error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* Passkey REST API Endpoints — Spec Section 3.7, 16.3
|
||||
* Passkey REST API Server — Spec Section 3.7, 16.3
|
||||
*
|
||||
* Provides REST endpoints for:
|
||||
* - POST /api/passkey/register/begin — Start registration
|
||||
* - POST /api/passkey/register/complete — Finish registration
|
||||
* - POST /api/passkey/authenticate/begin — Start authentication
|
||||
* - POST /api/passkey/authenticate/complete — Finish authentication
|
||||
* Proxies browser FIDO2 requests to PasskeyPlatformService (→ amiPro FIDO2 server).
|
||||
*
|
||||
* Endpoints:
|
||||
* POST /api/passkey/attestation/options — Get registration challenge
|
||||
* POST /api/passkey/attestation/result — Submit registration result
|
||||
* POST /api/passkey/assertion/options — Get authentication challenge
|
||||
* POST /api/passkey/assertion/result — Submit authentication result
|
||||
* POST /api/passkey/session/validate — Validate a session
|
||||
* POST /api/passkey/session/delete — Delete/logout a session
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
@@ -38,8 +42,8 @@ export class PasskeyAPIServer {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
|
||||
// CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', this.origin);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
@@ -56,94 +60,108 @@ export class PasskeyAPIServer {
|
||||
try {
|
||||
const body = await parseJsonBody(req) as Record<string, unknown>;
|
||||
|
||||
if (url.pathname === '/api/passkey/register/begin') {
|
||||
await this.handleRegisterBegin(res, body);
|
||||
} else if (url.pathname === '/api/passkey/register/complete') {
|
||||
await this.handleRegisterComplete(res, body);
|
||||
} else if (url.pathname === '/api/passkey/authenticate/begin') {
|
||||
await this.handleAuthenticateBegin(res, body);
|
||||
} else if (url.pathname === '/api/passkey/authenticate/complete') {
|
||||
await this.handleAuthenticateComplete(res, body);
|
||||
} else {
|
||||
sendJson(res, 404, { error: 'Not Found' });
|
||||
switch (url.pathname) {
|
||||
case '/api/passkey/attestation/options':
|
||||
await this.handleAttestationOptions(res, body);
|
||||
break;
|
||||
case '/api/passkey/attestation/result':
|
||||
await this.handleAttestationResult(res, body);
|
||||
break;
|
||||
case '/api/passkey/assertion/options':
|
||||
await this.handleAssertionOptions(res, body);
|
||||
break;
|
||||
case '/api/passkey/assertion/result':
|
||||
await this.handleAssertionResult(res, body);
|
||||
break;
|
||||
case '/api/passkey/session/validate':
|
||||
await this.handleSessionValidate(res, body);
|
||||
break;
|
||||
case '/api/passkey/session/delete':
|
||||
await this.handleSessionDelete(res, body);
|
||||
break;
|
||||
default:
|
||||
sendJson(res, 404, { error: 'Not Found' });
|
||||
}
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: (err as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRegisterBegin(
|
||||
// ─── Registration ────────────────────────────────────────────────────
|
||||
|
||||
private async handleAttestationOptions(
|
||||
res: http.ServerResponse,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const userId = body.userId as string;
|
||||
const displayName = body.displayName as string || userId;
|
||||
const username = body.username as string;
|
||||
const displayName = (body.displayName as string) || username;
|
||||
|
||||
if (!userId) {
|
||||
sendJson(res, 400, { error: 'userId is required' });
|
||||
if (!username) {
|
||||
sendJson(res, 400, { error: 'username is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.platformService.startRegistration(userId, displayName);
|
||||
sendJson(res, result.success ? 200 : 400, result);
|
||||
const result = await this.platformService.getAttestationOptions(username, displayName);
|
||||
sendJson(res, result.success ? 200 : 400, result.success ? result.options : { error: result.error });
|
||||
}
|
||||
|
||||
private async handleRegisterComplete(
|
||||
private async handleAttestationResult(
|
||||
res: http.ServerResponse,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const userId = body.userId as string;
|
||||
const challengeB64 = body.challenge as string;
|
||||
const attestationResponse = body.attestationResponse as Record<string, unknown>;
|
||||
const result = await this.platformService.submitAttestationResult(body as any);
|
||||
sendJson(res, result.success ? 200 : 400, result);
|
||||
}
|
||||
|
||||
if (!userId || !challengeB64 || !attestationResponse) {
|
||||
sendJson(res, 400, { error: 'Missing required fields' });
|
||||
// ─── Authentication ──────────────────────────────────────────────────
|
||||
|
||||
private async handleAssertionOptions(
|
||||
res: http.ServerResponse,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const username = body.username as string | undefined;
|
||||
|
||||
const result = await this.platformService.getAssertionOptions(username);
|
||||
sendJson(res, result.success ? 200 : 400, result.success ? result.options : { error: result.error });
|
||||
}
|
||||
|
||||
private async handleAssertionResult(
|
||||
res: http.ServerResponse,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const result = await this.platformService.submitAssertionResult(body as any);
|
||||
sendJson(res, result.success ? 200 : 400, result);
|
||||
}
|
||||
|
||||
// ─── Session ─────────────────────────────────────────────────────────
|
||||
|
||||
private async handleSessionValidate(
|
||||
res: http.ServerResponse,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const session = body.session as string;
|
||||
if (!session) {
|
||||
sendJson(res, 400, { error: 'session is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.platformService.completeRegistration(
|
||||
userId,
|
||||
challengeB64,
|
||||
attestationResponse as any,
|
||||
this.rpId,
|
||||
this.origin,
|
||||
);
|
||||
|
||||
sendJson(res, result.success ? 200 : 400, result);
|
||||
const valid = await this.platformService.validateSession(session);
|
||||
sendJson(res, 200, { valid });
|
||||
}
|
||||
|
||||
private async handleAuthenticateBegin(
|
||||
private async handleSessionDelete(
|
||||
res: http.ServerResponse,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const userId = body.userId as string | undefined;
|
||||
|
||||
const result = await this.platformService.startAuthentication(userId);
|
||||
sendJson(res, result.success ? 200 : 400, result);
|
||||
}
|
||||
|
||||
private async handleAuthenticateComplete(
|
||||
res: http.ServerResponse,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const credentialId = body.credentialId as string;
|
||||
const challengeB64 = body.challenge as string;
|
||||
const assertionResponse = body.assertionResponse as Record<string, unknown>;
|
||||
|
||||
if (!credentialId || !challengeB64 || !assertionResponse) {
|
||||
sendJson(res, 400, { error: 'Missing required fields' });
|
||||
const session = body.session as string;
|
||||
const username = body.username as string;
|
||||
if (!session || !username) {
|
||||
sendJson(res, 400, { error: 'session and username are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.platformService.completeAuthentication(
|
||||
credentialId,
|
||||
challengeB64,
|
||||
assertionResponse as any,
|
||||
this.rpId,
|
||||
this.origin,
|
||||
);
|
||||
|
||||
sendJson(res, result.success ? 200 : 400, result);
|
||||
await this.platformService.deleteSession(session, username);
|
||||
sendJson(res, 200, { ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
/**
|
||||
* Passkey Authentication Module — Public API
|
||||
* Exports all passkey-related types and services
|
||||
*/
|
||||
|
||||
export type { PasskeyCredential, PasskeyUser, IPasskeyStorage } from './storage.js';
|
||||
export { InMemoryPasskeyStorage } from './storage.js';
|
||||
|
||||
export type { PasskeyChallenge } from './challenge-manager.js';
|
||||
export { PasskeyChallengeManager } from './challenge-manager.js';
|
||||
|
||||
// External FIDO2 server client
|
||||
export type {
|
||||
RegistrationOptions,
|
||||
AuthenticationOptions,
|
||||
AttestationResponse,
|
||||
AssertionResponse,
|
||||
AttestationVerificationResult,
|
||||
AssertionVerificationResult,
|
||||
} from './fido2-backend.js';
|
||||
export { FIDO2Backend } from './fido2-backend.js';
|
||||
AmiProAttestationOptions,
|
||||
AmiProAssertionOptions,
|
||||
AmiProResult,
|
||||
AmiProDeviceList,
|
||||
} from './amipro-fido2-client.js';
|
||||
export { AmiProFIDO2Client } from './amipro-fido2-client.js';
|
||||
|
||||
// Platform service (orchestrates FIDO2 flows)
|
||||
export type { RegistrationResult, AuthenticationResult } from './platform-service.js';
|
||||
export { PasskeyPlatformService } from './platform-service.js';
|
||||
|
||||
// Servers
|
||||
export { PasskeyServer } from './passkey-server.js';
|
||||
export { PasskeyAPIServer } from './api-server.js';
|
||||
|
||||
// Storage (for local credential tracking if needed)
|
||||
export type { PasskeyCredential, PasskeyUser, IPasskeyStorage } from './storage.js';
|
||||
export { InMemoryPasskeyStorage } from './storage.js';
|
||||
|
||||
// Challenge manager (for custom flows)
|
||||
export type { PasskeyChallenge } from './challenge-manager.js';
|
||||
export { PasskeyChallengeManager } from './challenge-manager.js';
|
||||
|
||||
// Client (browser-side)
|
||||
export type { PasskeyAuthResult, PasskeyRegistrationResult } from './client.js';
|
||||
export { PasskeyClient } from './client.js';
|
||||
|
||||
export type { MCPletPasskeyHelperConfig } from './mcplet-helper.js';
|
||||
export { MCPletPasskeyHelper } from './mcplet-helper.js';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -11,36 +12,53 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
/**
|
||||
* Passkey Server — Spec Section 3.7, 16.3
|
||||
*
|
||||
* Provides two modes:
|
||||
* 1. Interactive Ceremony Mode: Opens a browser page for WebAuthn user interaction
|
||||
* - Binds exclusively to 127.0.0.1
|
||||
* - Port is dynamically allocated per ceremony
|
||||
* - Server closes immediately after assertion delivery or timeout
|
||||
* 2. REST API Mode: Provides HTTP endpoints for remote passkey operations
|
||||
*
|
||||
* Integrates with PasskeyPlatformService for FIDO2 backend operations.
|
||||
* Opens a self-contained browser page that handles the full passkey flow:
|
||||
* 1. User enters userId (or uses pre-filled value)
|
||||
* 2. Page checks with FIDO2 server whether user is registered
|
||||
* 3. If not registered → register passkey first
|
||||
* 4. Authenticate with passkey
|
||||
* 5. Return assertion payload to Host via loopback callback
|
||||
*/
|
||||
export class PasskeyServer {
|
||||
private readonly timeoutMs: number;
|
||||
private platformService: PasskeyPlatformService;
|
||||
private apiServer: http.Server | null = null;
|
||||
|
||||
constructor(
|
||||
rpId: string = 'localhost',
|
||||
origin: string = 'http://127.0.0.1',
|
||||
timeoutMs = 55_000,
|
||||
fido2ServerUrl: string = 'https://fido2.epk.amipro.me',
|
||||
timeoutMs = 120_000,
|
||||
) {
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.platformService = new PasskeyPlatformService(rpId, origin);
|
||||
this.platformService = new PasskeyPlatformService(rpId, fido2ServerUrl);
|
||||
}
|
||||
|
||||
getPlatformService(): PasskeyPlatformService {
|
||||
return this.platformService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Passkey Web Page and waits for the WebAuthn assertion.
|
||||
* Returns the assertion payload on success, throws on timeout or cancellation.
|
||||
* Opens the Passkey Web Page for the complete authentication flow.
|
||||
* The browser page handles registration + authentication automatically.
|
||||
*/
|
||||
async startCeremony(promptMessage: string): Promise<MCPletAuthPayload> {
|
||||
async startCeremony(
|
||||
promptMessage: string,
|
||||
username?: string,
|
||||
): Promise<MCPletAuthPayload> {
|
||||
const fido2ServerUrl = this.platformService.getFido2ServerUrl();
|
||||
const rpId = this.platformService.getRpId();
|
||||
|
||||
const isCustomRpId = rpId !== 'localhost' && rpId !== '127.0.0.1';
|
||||
const hostname = isCustomRpId ? rpId : '127.0.0.1';
|
||||
|
||||
const certDir = path.resolve(__dirname, '../../certs');
|
||||
const keyPath = path.join(certDir, 'key.pem');
|
||||
const certPath = path.join(certDir, 'cert.pem');
|
||||
const hasCerts = isCustomRpId && fs.existsSync(keyPath) && fs.existsSync(certPath);
|
||||
const useHttps = hasCerts && !process.env.PASSKEY_DEV_HTTP;
|
||||
const protocol = useHttps ? 'https' : 'http';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let server: http.Server | null = null;
|
||||
let server: http.Server | https.Server | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
@@ -48,26 +66,36 @@ export class PasskeyServer {
|
||||
server?.close();
|
||||
};
|
||||
|
||||
server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://localhost`);
|
||||
const requestHandler = (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
const url = new URL(req.url ?? '/', `${protocol}://${hostname}`);
|
||||
|
||||
// Serve the Passkey Web Page
|
||||
if (req.method === 'GET' && url.pathname === '/passkey') {
|
||||
servePasskeyPage(res, promptMessage, url.searchParams.get('port') ?? '');
|
||||
const port = url.searchParams.get('port') ?? '';
|
||||
const html = buildPasskeyPage(
|
||||
promptMessage, port, username ?? '', fido2ServerUrl, rpId,
|
||||
protocol, hostname,
|
||||
);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Security-Policy':
|
||||
"default-src 'self' 'unsafe-inline' https://*.amipro.me; " +
|
||||
`connect-src 'self' https://*.amipro.me ${protocol}://${hostname}:*; ` +
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
});
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
|
||||
// Receive assertion callback (POST /passkey/callback)
|
||||
if (req.method === 'POST' && url.pathname === '/passkey/callback') {
|
||||
let data = '';
|
||||
req.on('data', (chunk: Buffer) => { data += chunk.toString(); });
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const assertion = JSON.parse(data) as MCPletAuthPayload;
|
||||
const payload = JSON.parse(data) as MCPletAuthPayload;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
cleanup();
|
||||
resolve(assertion);
|
||||
resolve(payload);
|
||||
} catch {
|
||||
res.writeHead(400);
|
||||
res.end();
|
||||
@@ -76,7 +104,6 @@ export class PasskeyServer {
|
||||
return;
|
||||
}
|
||||
|
||||
// User cancelled
|
||||
if (req.method === 'POST' && url.pathname === '/passkey/cancel') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
@@ -87,23 +114,32 @@ export class PasskeyServer {
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
};
|
||||
|
||||
if (useHttps) {
|
||||
server = https.createServer({
|
||||
key: fs.readFileSync(keyPath),
|
||||
cert: fs.readFileSync(certPath),
|
||||
}, requestHandler);
|
||||
} else {
|
||||
server = http.createServer(requestHandler);
|
||||
}
|
||||
|
||||
// Bind to loopback with port 0 (OS assigns dynamic port)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server!.address();
|
||||
if (!addr || typeof addr === 'string') {
|
||||
reject(new Error('Failed to bind Passkey server'));
|
||||
return;
|
||||
}
|
||||
|
||||
const port = addr.port;
|
||||
const pageUrl = `http://127.0.0.1:${port}/passkey?port=${port}`;
|
||||
const pageUrl = `${protocol}://${hostname}:${port}/passkey?port=${port}`;
|
||||
console.log(`[passkey] Opening ceremony page: ${pageUrl}`);
|
||||
if (isCustomRpId) {
|
||||
console.log(`[passkey] Ensure /etc/hosts has: 127.0.0.1 ${rpId}`);
|
||||
}
|
||||
openBrowser(pageUrl);
|
||||
});
|
||||
|
||||
// Timeout — close page and reject (Spec Section 3.7)
|
||||
timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('Passkey ceremony timed out'));
|
||||
@@ -112,75 +148,285 @@ export class PasskeyServer {
|
||||
}
|
||||
}
|
||||
|
||||
function servePasskeyPage(res: http.ServerResponse, promptMessage: string, port: string): void {
|
||||
// Try to serve static file first, fall back to inline
|
||||
const staticPath = path.resolve(__dirname, '../../public/passkey/index.html');
|
||||
if (fs.existsSync(staticPath)) {
|
||||
const html = fs.readFileSync(staticPath, 'utf-8')
|
||||
.replace('{{PROMPT_MESSAGE}}', escapeHtml(promptMessage))
|
||||
.replace('{{PORT}}', port);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html',
|
||||
'Content-Security-Policy':
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'",
|
||||
});
|
||||
res.end(html);
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(inlinePasskeyPage(promptMessage, port));
|
||||
}
|
||||
}
|
||||
// ─── Browser Page ──────────────────────────────────────────────────────
|
||||
|
||||
function buildPasskeyPage(
|
||||
promptMessage: string,
|
||||
port: string,
|
||||
prefilledUsername: string,
|
||||
fido2ServerUrl: string,
|
||||
rpId: string,
|
||||
protocol: string,
|
||||
hostname: string,
|
||||
): string {
|
||||
const callbackBase = `${protocol}://${hostname}:${port}`;
|
||||
|
||||
function inlinePasskeyPage(promptMessage: string, port: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>認証</title>
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'">
|
||||
<title>Passkey 認証 — MCPlet</title>
|
||||
<style>
|
||||
body { font-family: system-ui; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; height: 100vh; margin: 0; }
|
||||
button { padding: 12px 32px; font-size: 16px; cursor: pointer; }
|
||||
.msg { margin-bottom: 24px; font-size: 18px; text-align: center; }
|
||||
.cancel { margin-top: 12px; color: #666; background: none; border: none; cursor: pointer; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex;
|
||||
align-items: center; justify-content: center; height: 100vh; margin: 0;
|
||||
background: #f8f9fa; color: #333; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 36px 40px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08); max-width: 440px;
|
||||
width: 100%; text-align: center; }
|
||||
h2 { margin: 0 0 8px; font-size: 20px; }
|
||||
.msg { margin-bottom: 20px; font-size: 14px; color: #555; line-height: 1.5; }
|
||||
.field { margin-bottom: 20px; text-align: left; }
|
||||
label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; color: #444; }
|
||||
input { width: 100%; padding: 10px 12px; font-size: 15px; border: 1px solid #ccc;
|
||||
border-radius: 6px; outline: none; }
|
||||
input:focus { border-color: #0066ff; box-shadow: 0 0 0 2px rgba(0,102,255,0.15); }
|
||||
.btn { width: 100%; padding: 14px; font-size: 16px; cursor: pointer; border: none;
|
||||
border-radius: 8px; font-weight: 600; transition: all 0.2s; }
|
||||
.btn-primary { background: #0066ff; color: #fff; }
|
||||
.btn-primary:hover { background: #0052cc; }
|
||||
.btn-primary:disabled { background: #ccc; cursor: not-allowed; }
|
||||
.cancel { margin-top: 12px; color: #888; background: none; border: none;
|
||||
cursor: pointer; font-size: 13px; }
|
||||
.status { margin-top: 16px; font-size: 13px; min-height: 18px; }
|
||||
.error { color: #d32f2f; }
|
||||
.success { color: #2e7d32; }
|
||||
.info { color: #1565c0; }
|
||||
.step { color: #666; }
|
||||
.icon { font-size: 40px; margin-bottom: 12px; }
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p class="msg">${escapeHtml(promptMessage)}</p>
|
||||
<button id="authBtn">Passkey で認証する</button>
|
||||
<button class="cancel" id="cancelBtn">キャンセル</button>
|
||||
<script>
|
||||
const PORT = ${port};
|
||||
document.getElementById('authBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
// In a real deployment: fetch challenge from FIDO2 server, then call navigator.credentials.get()
|
||||
// For demo: simulate a successful assertion
|
||||
const mockAssertion = {
|
||||
type: 'passkey_assertion',
|
||||
challenge: 'demo-challenge-' + Date.now(),
|
||||
clientDataJSON: btoa(JSON.stringify({ type: 'webauthn.get', challenge: 'demo' })),
|
||||
authenticatorData: btoa('demo-authenticator-data'),
|
||||
signature: btoa('demo-signature'),
|
||||
userHandle: btoa('demo-user')
|
||||
};
|
||||
await fetch('http://127.0.0.1:' + PORT + '/passkey/callback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mockAssertion)
|
||||
});
|
||||
document.body.innerHTML = '<p>認証完了。このウィンドウを閉じてください。</p>';
|
||||
setTimeout(() => window.close(), 1500);
|
||||
} catch (e) {
|
||||
alert('認証エラー: ' + e.message);
|
||||
}
|
||||
<div class="card">
|
||||
<div class="icon">🔐</div>
|
||||
<h2>Passkey 認証</h2>
|
||||
<p class="msg">${escapeHtml(promptMessage)}</p>
|
||||
|
||||
<div class="field" id="userField">
|
||||
<label for="userId">ユーザー ID</label>
|
||||
<input type="text" id="userId" value="${escapeHtml(prefilledUsername)}"
|
||||
placeholder="your-username" autocomplete="username webauthn">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" id="startBtn">認証を開始する</button>
|
||||
<br>
|
||||
<button class="cancel" id="cancelBtn">キャンセル</button>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var CALLBACK_BASE = '${callbackBase}';
|
||||
var FIDO2_SVR = '${fido2ServerUrl}';
|
||||
var RP_ID = '${rpId}';
|
||||
|
||||
var statusEl = document.getElementById('status');
|
||||
var startBtn = document.getElementById('startBtn');
|
||||
var userIdInput = document.getElementById('userId');
|
||||
|
||||
function setStatus(msg, type) {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = 'status ' + (type || '');
|
||||
}
|
||||
|
||||
// ── Base64URL helpers (matching dfido2-lib.js) ──
|
||||
function toB64U(s) { return s.split('=')[0].replace(/[+]/g,'-').replace(/[/]/g,'_'); }
|
||||
function fromB64U(s) {
|
||||
s = s.replace(/-/g,'+').replace(/_/g,'/');
|
||||
var p = s.length % 4;
|
||||
if (p) { if (p===1) throw new Error('bad b64u'); s += '===='.slice(p); }
|
||||
return s;
|
||||
}
|
||||
function b64ToBuf(b) {
|
||||
var d = atob(b), a = new Uint8Array(d.length);
|
||||
for (var i=0;i<d.length;i++) a[i]=d.charCodeAt(i);
|
||||
return a;
|
||||
}
|
||||
function bufToStr(b) { return String.fromCharCode.apply(null, new Uint8Array(b)); }
|
||||
function strToBuf(s) { return new Uint8Array([].map.call(s,function(c){return c.charCodeAt(0)})).buffer; }
|
||||
|
||||
// ── FIDO2 server helpers ──
|
||||
async function fido2Post(path, body) {
|
||||
var r = await fetch(FIDO2_SVR + path, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
|
||||
});
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// ── Check if user is registered ──
|
||||
async function isUserRegistered(userId) {
|
||||
try {
|
||||
var resp = await fido2Post('/assertion/options', {
|
||||
username: userId,
|
||||
authenticatorSelection: { userVerification: 'preferred' },
|
||||
rp: { id: RP_ID }
|
||||
});
|
||||
document.getElementById('cancelBtn').addEventListener('click', async () => {
|
||||
await fetch('http://127.0.0.1:' + PORT + '/passkey/cancel', { method: 'POST' });
|
||||
window.close();
|
||||
return resp.status === 'ok';
|
||||
} catch(e) { return false; }
|
||||
}
|
||||
|
||||
// ── Registration ──
|
||||
async function doRegister(userId) {
|
||||
setStatus('登録オプションを取得中...', 'step');
|
||||
var opts = await fido2Post('/attestation/options', {
|
||||
username: userId,
|
||||
displayName: encodeURIComponent(userId),
|
||||
authenticatorSelection: { userVerification:'preferred', requireResidentKey:false },
|
||||
rp: { id: RP_ID }
|
||||
});
|
||||
if (opts.status === 'failed') throw new Error(opts.errorMessage || 'Server error');
|
||||
|
||||
var pk = {
|
||||
rp: opts.rp,
|
||||
user: { id: strToBuf(opts.user.id), name: opts.user.name||opts.user.id, displayName: opts.user.displayName||opts.user.name||opts.user.id },
|
||||
pubKeyCredParams: opts.pubKeyCredParams,
|
||||
timeout: opts.timeout,
|
||||
challenge: b64ToBuf(fromB64U(opts.challenge))
|
||||
};
|
||||
if (opts.excludeCredentials) pk.excludeCredentials = opts.excludeCredentials.map(function(x){ return {id:b64ToBuf(fromB64U(x.id)),type:x.type}; });
|
||||
if (opts.authenticatorSelection) pk.authenticatorSelection = opts.authenticatorSelection;
|
||||
|
||||
setStatus('認証器を使って登録してください...', 'info');
|
||||
var cred = await navigator.credentials.create({ publicKey: pk });
|
||||
if (!cred) throw new Error('Credential not returned');
|
||||
|
||||
setStatus('登録結果を検証中...', 'step');
|
||||
var att = {
|
||||
id: cred.id,
|
||||
rawId: toB64U(btoa(bufToStr(cred.rawId))),
|
||||
type: 'public-key',
|
||||
response: {
|
||||
clientDataJSON: toB64U(btoa(bufToStr(cred.response.clientDataJSON))),
|
||||
attestationObject: toB64U(btoa(bufToStr(cred.response.attestationObject)))
|
||||
}
|
||||
};
|
||||
if (cred.response.getTransports) att.transports = cred.response.getTransports();
|
||||
|
||||
var result = await fido2Post('/attestation/result', att);
|
||||
if (result.status !== 'ok') throw new Error(result.errorMessage || 'Registration failed');
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Authentication ──
|
||||
async function doAuthenticate(userId) {
|
||||
setStatus('認証オプションを取得中...', 'step');
|
||||
var opts = await fido2Post('/assertion/options', {
|
||||
username: userId,
|
||||
authenticatorSelection: { userVerification:'preferred' },
|
||||
rp: { id: RP_ID }
|
||||
});
|
||||
if (opts.status === 'failed') throw new Error(opts.errorMessage || 'Server error');
|
||||
|
||||
var allowCreds = (opts.allowCredentials||[]).map(function(x){
|
||||
return { id: b64ToBuf(fromB64U(x.id)), type: x.type, transports: x.transports };
|
||||
});
|
||||
|
||||
setStatus('認証器を使って認証してください...', 'info');
|
||||
var cred = await navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge: b64ToBuf(fromB64U(opts.challenge)),
|
||||
timeout: opts.timeout,
|
||||
rpId: opts.rpId || RP_ID,
|
||||
userVerification: opts.userVerification || 'preferred',
|
||||
allowCredentials: allowCreds
|
||||
}
|
||||
});
|
||||
if (!cred) throw new Error('Credential not returned');
|
||||
|
||||
setStatus('認証結果を検証中...', 'step');
|
||||
var auth = {
|
||||
id: cred.id,
|
||||
rawId: Array.from(new Uint8Array(cred.rawId)),
|
||||
type: cred.type,
|
||||
response: {
|
||||
authenticatorData: toB64U(btoa(bufToStr(cred.response.authenticatorData))),
|
||||
clientDataJSON: toB64U(btoa(bufToStr(cred.response.clientDataJSON))),
|
||||
signature: toB64U(btoa(bufToStr(cred.response.signature))),
|
||||
userHandle: toB64U(btoa(bufToStr(cred.response.userHandle)))
|
||||
}
|
||||
};
|
||||
|
||||
var result = await fido2Post('/assertion/result', auth);
|
||||
if (result.status !== 'ok') throw new Error(result.errorMessage || 'Authentication failed');
|
||||
|
||||
return {
|
||||
opts: opts,
|
||||
auth: auth,
|
||||
result: result
|
||||
};
|
||||
}
|
||||
|
||||
// ── Main Flow ──
|
||||
async function startFlow() {
|
||||
var userId = userIdInput.value.trim();
|
||||
if (!userId) { setStatus('ユーザー ID を入力してください', 'error'); return; }
|
||||
|
||||
startBtn.disabled = true;
|
||||
userIdInput.disabled = true;
|
||||
|
||||
try {
|
||||
// Step 1: Check if user is registered
|
||||
setStatus('ユーザーを確認中...', 'step');
|
||||
var registered = await isUserRegistered(userId);
|
||||
|
||||
// Step 2: If not registered, register first
|
||||
if (!registered) {
|
||||
setStatus('Passkey が未登録です。登録を開始します...', 'info');
|
||||
await doRegister(userId);
|
||||
setStatus('登録成功! 続けて認証します...', 'success');
|
||||
}
|
||||
|
||||
// Step 3: Authenticate
|
||||
var authData = await doAuthenticate(userId);
|
||||
|
||||
// Step 4: Send payload back to Host
|
||||
var payload = {
|
||||
type: 'passkey_assertion',
|
||||
challenge: authData.opts.challenge,
|
||||
clientDataJSON: authData.auth.response.clientDataJSON,
|
||||
authenticatorData: authData.auth.response.authenticatorData,
|
||||
signature: authData.auth.response.signature,
|
||||
userHandle: authData.auth.response.userHandle,
|
||||
session: authData.result.session,
|
||||
username: authData.result.username
|
||||
};
|
||||
|
||||
await fetch(CALLBACK_BASE + '/passkey/callback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
</script>
|
||||
|
||||
setStatus('認証成功! (' + (authData.result.username||userId) + ')', 'success');
|
||||
setTimeout(function(){ window.close(); }, 2000);
|
||||
|
||||
} catch (e) {
|
||||
if (e.name === 'InvalidStateError') {
|
||||
setStatus('このデバイスは既に登録済みです', 'error');
|
||||
} else if (e.name === 'NotAllowedError') {
|
||||
setStatus('操作がキャンセルされました', 'error');
|
||||
} else {
|
||||
setStatus('エラー: ' + e.message, 'error');
|
||||
}
|
||||
startBtn.disabled = false;
|
||||
userIdInput.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events ──
|
||||
startBtn.addEventListener('click', startFlow);
|
||||
userIdInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') startFlow(); });
|
||||
document.getElementById('cancelBtn').addEventListener('click', async function() {
|
||||
await fetch(CALLBACK_BASE + '/passkey/cancel', { method: 'POST' });
|
||||
window.close();
|
||||
});
|
||||
|
||||
// Auto-start if username is pre-filled
|
||||
if (userIdInput.value.trim()) {
|
||||
// Give browser a moment to render, then auto-start
|
||||
setTimeout(startFlow, 500);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -200,11 +446,7 @@ function platformOpenCmd(url: string): string {
|
||||
}
|
||||
|
||||
function openBrowser(url: string): void {
|
||||
import('node:child_process').then(({ exec }) => {
|
||||
exec(platformOpenCmd(url), (err) => {
|
||||
if (err) console.warn(`[passkey] Could not open browser: ${err.message}`);
|
||||
});
|
||||
}).catch(() => {
|
||||
console.warn('[passkey] child_process not available');
|
||||
exec(platformOpenCmd(url), (err) => {
|
||||
if (err) console.warn(`[passkey] Could not open browser: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,262 +1,190 @@
|
||||
/**
|
||||
* Passkey Platform Service — Unified Passkey Management
|
||||
* Consolidates storage, challenge management, and FIDO2 backend
|
||||
* Spec Section 3.7, 7.3.1
|
||||
*
|
||||
* Delegates challenge generation and cryptographic verification to an external
|
||||
* FIDO2 server (amiPro). This service:
|
||||
* - Proxies attestation/assertion options from FIDO2 server to browser
|
||||
* - Proxies attestation/assertion results from browser to FIDO2 server
|
||||
* - Manages session lifecycle
|
||||
*/
|
||||
|
||||
import type { PasskeyCredential } from './storage.js';
|
||||
import { InMemoryPasskeyStorage, type IPasskeyStorage } from './storage.js';
|
||||
import { PasskeyChallengeManager } from './challenge-manager.js';
|
||||
import { FIDO2Backend, type AttestationResponse, type AssertionResponse } from './fido2-backend.js';
|
||||
import { AmiProFIDO2Client } from './amipro-fido2-client.js';
|
||||
import type {
|
||||
AmiProAttestationOptions,
|
||||
AmiProAssertionOptions,
|
||||
AmiProResult,
|
||||
} from './amipro-fido2-client.js';
|
||||
|
||||
export interface RegistrationResult {
|
||||
success: boolean;
|
||||
credentialId?: string;
|
||||
userId?: string;
|
||||
session?: string;
|
||||
username?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AuthenticationResult {
|
||||
success: boolean;
|
||||
userId?: string;
|
||||
credentialId?: string;
|
||||
session?: string;
|
||||
username?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passkey Platform Service
|
||||
* High-level API for passkey registration and authentication
|
||||
* Agnostic to transport layer (HTTP, WebSocket, etc.)
|
||||
*
|
||||
* High-level API for passkey registration and authentication.
|
||||
* All cryptographic operations are performed by the external FIDO2 server.
|
||||
*/
|
||||
export class PasskeyPlatformService {
|
||||
private storage: IPasskeyStorage;
|
||||
private challengeManager: PasskeyChallengeManager;
|
||||
private fido2Backend: FIDO2Backend;
|
||||
private fido2Client: AmiProFIDO2Client;
|
||||
|
||||
constructor(
|
||||
rpId: string,
|
||||
origin: string,
|
||||
storage?: IPasskeyStorage,
|
||||
private readonly rpId: string,
|
||||
private readonly fido2ServerUrl: string,
|
||||
) {
|
||||
this.storage = storage ?? new InMemoryPasskeyStorage();
|
||||
this.challengeManager = new PasskeyChallengeManager(rpId, origin);
|
||||
this.fido2Backend = new FIDO2Backend();
|
||||
this.fido2Client = new AmiProFIDO2Client(fido2ServerUrl, rpId);
|
||||
}
|
||||
|
||||
// ─── Registration ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start registration ceremony
|
||||
* Returns challenge for the client to use in WebAuthn.create()
|
||||
* Step 1: Get attestation options from FIDO2 server.
|
||||
* Returns challenge + publicKey params for browser's navigator.credentials.create()
|
||||
*/
|
||||
async startRegistration(userId: string, displayName: string): Promise<{
|
||||
success: boolean;
|
||||
challenge?: string;
|
||||
userId?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
async getAttestationOptions(
|
||||
username: string,
|
||||
displayName: string,
|
||||
): Promise<{ success: boolean; options?: AmiProAttestationOptions; error?: string }> {
|
||||
try {
|
||||
// Create user if it doesn't exist
|
||||
if (!(await this.storage.userExists(userId))) {
|
||||
await this.storage.createUser(userId, displayName);
|
||||
const options = await this.fido2Client.getAttestationOptions(username, displayName);
|
||||
if (options.status === 'failed') {
|
||||
return { success: false, error: options.errorMessage };
|
||||
}
|
||||
|
||||
// Generate challenge
|
||||
const challenge = this.challengeManager.generateChallenge(userId, 'registration');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
challenge,
|
||||
userId,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify registration ceremony response
|
||||
* Client sends attestation response after WebAuthn.create()
|
||||
*/
|
||||
async completeRegistration(
|
||||
userId: string,
|
||||
challengeB64: string,
|
||||
attestationResponse: AttestationResponse,
|
||||
rpId: string,
|
||||
origin: string,
|
||||
): Promise<RegistrationResult> {
|
||||
try {
|
||||
// Validate challenge
|
||||
const challenge = this.challengeManager.validateChallenge(challengeB64, userId, 'registration');
|
||||
if (!challenge) {
|
||||
return { success: false, error: 'Invalid or expired challenge' };
|
||||
}
|
||||
|
||||
// Verify attestation
|
||||
const attestResult = this.fido2Backend.verifyAttestation(
|
||||
attestationResponse,
|
||||
challengeB64,
|
||||
rpId,
|
||||
origin,
|
||||
);
|
||||
|
||||
if (!attestResult.success) {
|
||||
return { success: false, error: attestResult.error };
|
||||
}
|
||||
|
||||
// Store credential
|
||||
const credential: PasskeyCredential = {
|
||||
id: attestResult.credentialId,
|
||||
publicKey: attestResult.publicKey,
|
||||
counter: attestResult.counter,
|
||||
transports: attestResult.transports,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.storage.addCredential(userId, credential);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
credentialId: attestResult.credentialId,
|
||||
userId,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start authentication ceremony
|
||||
* Returns challenge for the client to use in WebAuthn.get()
|
||||
*/
|
||||
async startAuthentication(userId?: string): Promise<{
|
||||
success: boolean;
|
||||
challenge?: string;
|
||||
allowCredentials?: Array<{ id: string; type: string }>;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// If userId provided, generate user-specific challenge;
|
||||
// otherwise, generate challenge for resident key
|
||||
const ceremonyUserId = userId || 'any-user';
|
||||
|
||||
const challenge = this.challengeManager.generateChallenge(ceremonyUserId, 'authentication');
|
||||
|
||||
const allowCredentials = userId
|
||||
? (await this.storage.getCredentialsByUserId(userId))
|
||||
.map(cred => ({ id: cred.id, type: 'public-key' }))
|
||||
: [];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
challenge,
|
||||
allowCredentials,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify authentication ceremony response
|
||||
* Client sends assertion response after WebAuthn.get()
|
||||
*/
|
||||
async completeAuthentication(
|
||||
credentialId: string,
|
||||
challengeB64: string,
|
||||
assertionResponse: AssertionResponse,
|
||||
rpId: string,
|
||||
origin: string,
|
||||
): Promise<AuthenticationResult> {
|
||||
try {
|
||||
// Find credential and associated user
|
||||
const credential = await this.storage.getCredential(credentialId);
|
||||
if (!credential) {
|
||||
return { success: false, error: 'Credential not found' };
|
||||
}
|
||||
|
||||
// Determine user ID (credential is unique, so only one user can own it)
|
||||
let userId = '';
|
||||
const allUsers = await this.storage.getUser(assertionResponse.response.userHandle || '');
|
||||
if (!allUsers) {
|
||||
// Try to find user by credential
|
||||
for (const [uid] of Object.entries({})) {
|
||||
const creds = await this.storage.getCredentialsByUserId(uid);
|
||||
if (creds.some(c => c.id === credentialId)) {
|
||||
userId = uid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
userId = allUsers.userId;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return { success: false, error: 'Could not determine user for credential' };
|
||||
}
|
||||
|
||||
// Validate challenge (note: we need to validate against the correct ceremony userId)
|
||||
const challenge = this.challengeManager.validateChallenge(challengeB64, userId, 'authentication');
|
||||
if (!challenge) {
|
||||
return { success: false, error: 'Invalid or expired challenge' };
|
||||
}
|
||||
|
||||
// Verify assertion
|
||||
const assertResult = this.fido2Backend.verifyAssertion(
|
||||
assertionResponse,
|
||||
challengeB64,
|
||||
credential.publicKey,
|
||||
credential.counter,
|
||||
rpId,
|
||||
origin,
|
||||
);
|
||||
|
||||
if (!assertResult.success) {
|
||||
return { success: false, error: assertResult.error };
|
||||
}
|
||||
|
||||
// Update credential counter
|
||||
await this.storage.updateCredentialCounter(credentialId, assertResult.counter);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId,
|
||||
credentialId,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's credentials (for management UI)
|
||||
*/
|
||||
async getUserCredentials(userId: string): Promise<PasskeyCredential[]> {
|
||||
return this.storage.getCredentialsByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a credential
|
||||
*/
|
||||
async deleteCredential(userId: string, credentialId: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await this.storage.deleteCredential(userId, credentialId);
|
||||
return { success: true };
|
||||
return { success: true, options };
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3: Submit attestation result to FIDO2 server for verification.
|
||||
* Called after browser completed navigator.credentials.create()
|
||||
*/
|
||||
async submitAttestationResult(attestationResult: {
|
||||
id: string;
|
||||
rawId: string;
|
||||
type: string;
|
||||
response: {
|
||||
clientDataJSON: string;
|
||||
attestationObject: string;
|
||||
};
|
||||
transports?: string[];
|
||||
}): Promise<RegistrationResult> {
|
||||
try {
|
||||
const result = await this.fido2Client.submitAttestationResult(attestationResult);
|
||||
if (result.status === 'ok') {
|
||||
return {
|
||||
success: true,
|
||||
session: result.session,
|
||||
username: result.username,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.errorMessage };
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Authentication ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Step 1: Get assertion options from FIDO2 server.
|
||||
* Returns challenge + allowCredentials for browser's navigator.credentials.get()
|
||||
*/
|
||||
async getAssertionOptions(
|
||||
username?: string,
|
||||
): Promise<{ success: boolean; options?: AmiProAssertionOptions; error?: string }> {
|
||||
try {
|
||||
const options = await this.fido2Client.getAssertionOptions(username ?? null);
|
||||
if (options.status === 'failed') {
|
||||
return { success: false, error: options.errorMessage };
|
||||
}
|
||||
return { success: true, options };
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3: Submit assertion result to FIDO2 server for verification.
|
||||
* Called after browser completed navigator.credentials.get()
|
||||
*/
|
||||
async submitAssertionResult(assertionResult: {
|
||||
id: string;
|
||||
rawId: number[] | string;
|
||||
type: string;
|
||||
response: {
|
||||
authenticatorData: string;
|
||||
clientDataJSON: string;
|
||||
signature: string;
|
||||
userHandle: string;
|
||||
};
|
||||
}): Promise<AuthenticationResult> {
|
||||
try {
|
||||
const result = await this.fido2Client.submitAssertionResult(assertionResult);
|
||||
if (result.status === 'ok') {
|
||||
return {
|
||||
success: true,
|
||||
session: result.session,
|
||||
username: result.username,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.errorMessage };
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Session Management ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate a FIDO2 session.
|
||||
*/
|
||||
async validateSession(session: string): Promise<boolean> {
|
||||
return this.fido2Client.validateSession(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout / invalidate a session.
|
||||
*/
|
||||
async deleteSession(session: string, username: string): Promise<void> {
|
||||
await this.fido2Client.deleteSession(session, username);
|
||||
}
|
||||
|
||||
// ─── Device Management ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List user's registered devices.
|
||||
*/
|
||||
async listDevices(session: string) {
|
||||
return this.fido2Client.listDevices(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user device.
|
||||
*/
|
||||
async deleteDevice(session: string, deviceId: string) {
|
||||
return this.fido2Client.deleteDevice(session, deviceId);
|
||||
}
|
||||
|
||||
// ─── Accessors ───────────────────────────────────────────────────────
|
||||
|
||||
getRpId(): string {
|
||||
return this.rpId;
|
||||
}
|
||||
|
||||
getFido2ServerUrl(): string {
|
||||
return this.fido2ServerUrl;
|
||||
}
|
||||
}
|
||||
|
||||
140
platform_impl/test-passkey.ts
Normal file
140
platform_impl/test-passkey.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* End-to-end test for Passkey authentication against amiPro FIDO2 server.
|
||||
*
|
||||
* Tests:
|
||||
* 1. AmiProFIDO2Client connectivity
|
||||
* 2. PasskeyPlatformService flow
|
||||
* 3. PasskeyServer interactive ceremony (opens browser)
|
||||
*
|
||||
* Usage: npx tsx test-passkey.ts [register|authenticate]
|
||||
*/
|
||||
|
||||
import { AmiProFIDO2Client } from './src/passkey/amipro-fido2-client.js';
|
||||
import { PasskeyPlatformService } from './src/passkey/platform-service.js';
|
||||
import { PasskeyServer } from './src/passkey/passkey-server.js';
|
||||
|
||||
const FIDO2_SERVER_URL = 'https://fido2.epk.amipro.me';
|
||||
const RP_ID = 'a2a-demo.mcplet.ai';
|
||||
const TEST_USER = 'a2a-test-user';
|
||||
|
||||
async function testAmiProClient() {
|
||||
console.log('\n=== Test 1: AmiProFIDO2Client Connectivity ===\n');
|
||||
|
||||
const client = new AmiProFIDO2Client(FIDO2_SERVER_URL, RP_ID);
|
||||
|
||||
// Test attestation options (registration)
|
||||
console.log('[1a] Getting attestation options...');
|
||||
try {
|
||||
const attOpts = await client.getAttestationOptions(TEST_USER, 'A2A Test User');
|
||||
console.log(' status:', attOpts.status);
|
||||
if (attOpts.status !== 'failed') {
|
||||
console.log(' challenge:', attOpts.challenge?.substring(0, 30) + '...');
|
||||
console.log(' rp:', JSON.stringify(attOpts.rp));
|
||||
console.log(' user.id:', attOpts.user?.id?.substring(0, 20) + '...');
|
||||
console.log(' pubKeyCredParams:', JSON.stringify(attOpts.pubKeyCredParams));
|
||||
console.log(' timeout:', attOpts.timeout);
|
||||
} else {
|
||||
console.log(' errorMessage:', attOpts.errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(' ERROR:', (err as Error).message);
|
||||
}
|
||||
|
||||
// Test assertion options (authentication)
|
||||
console.log('\n[1b] Getting assertion options...');
|
||||
try {
|
||||
const assertOpts = await client.getAssertionOptions(TEST_USER);
|
||||
console.log(' status:', assertOpts.status);
|
||||
if (assertOpts.status === 'ok') {
|
||||
console.log(' challenge:', assertOpts.challenge?.substring(0, 30) + '...');
|
||||
console.log(' rpId:', assertOpts.rpId);
|
||||
console.log(' timeout:', assertOpts.timeout);
|
||||
console.log(' allowCredentials:', assertOpts.allowCredentials?.length, 'entries');
|
||||
console.log(' userVerification:', assertOpts.userVerification);
|
||||
} else {
|
||||
console.log(' errorMessage:', assertOpts.errorMessage);
|
||||
console.log(' (Expected: user not registered yet)');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(' ERROR:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testPlatformService() {
|
||||
console.log('\n=== Test 2: PasskeyPlatformService ===\n');
|
||||
|
||||
const service = new PasskeyPlatformService(RP_ID, FIDO2_SERVER_URL);
|
||||
|
||||
// Test get attestation options
|
||||
console.log('[2a] Getting attestation options via service...');
|
||||
const attResult = await service.getAttestationOptions(TEST_USER, 'A2A Test User');
|
||||
console.log(' success:', attResult.success);
|
||||
if (attResult.success) {
|
||||
console.log(' challenge present:', !!attResult.options?.challenge);
|
||||
console.log(' rp:', JSON.stringify(attResult.options?.rp));
|
||||
} else {
|
||||
console.log(' error:', attResult.error);
|
||||
}
|
||||
|
||||
// Test get assertion options
|
||||
console.log('\n[2b] Getting assertion options via service...');
|
||||
const assertResult = await service.getAssertionOptions(TEST_USER);
|
||||
console.log(' success:', assertResult.success);
|
||||
if (assertResult.success) {
|
||||
console.log(' challenge present:', !!assertResult.options?.challenge);
|
||||
console.log(' allowCredentials:', assertResult.options?.allowCredentials?.length, 'entries');
|
||||
} else {
|
||||
console.log(' error:', assertResult.error);
|
||||
console.log(' (Expected if user not registered yet)');
|
||||
}
|
||||
}
|
||||
|
||||
async function testInteractiveCeremony(username?: string) {
|
||||
console.log(`\n=== Test 3: Interactive Passkey Ceremony ===\n`);
|
||||
console.log(`Opens a browser page that auto-detects register vs authenticate.`);
|
||||
console.log(`rpId: ${RP_ID}, FIDO2 server: ${FIDO2_SERVER_URL}`);
|
||||
if (username) console.log(`Pre-filled username: ${username}`);
|
||||
console.log();
|
||||
|
||||
const server = new PasskeyServer(RP_ID, FIDO2_SERVER_URL, 180_000);
|
||||
|
||||
try {
|
||||
const payload = await server.startCeremony(
|
||||
`この操作には Passkey 認証が必要です`,
|
||||
username,
|
||||
);
|
||||
|
||||
console.log('\n Ceremony succeeded!');
|
||||
console.log(' type:', payload.type);
|
||||
console.log(' challenge:', payload.challenge?.substring(0, 30) + '...');
|
||||
console.log(' session:', (payload as any).session);
|
||||
console.log(' username:', (payload as any).username);
|
||||
} catch (err) {
|
||||
console.error('\n Ceremony failed:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main ──────────────────────────────────────────────────────────────
|
||||
|
||||
const arg = process.argv[2];
|
||||
|
||||
await testAmiProClient();
|
||||
await testPlatformService();
|
||||
|
||||
if (arg === 'test') {
|
||||
// Interactive test: opens browser with unified flow
|
||||
// Pass a username to pre-fill, or omit to let user enter it
|
||||
const username = process.argv[3] || undefined;
|
||||
await testInteractiveCeremony(username);
|
||||
} else if (arg === 'new') {
|
||||
// Test with a brand new user (no pre-fill)
|
||||
await testInteractiveCeremony();
|
||||
} else {
|
||||
console.log('\n=== Interactive test skipped ===');
|
||||
console.log('Usage:');
|
||||
console.log(' npx tsx test-passkey.ts test [username] — unified flow (auto register+auth)');
|
||||
console.log(' npx tsx test-passkey.ts new — new user (empty username field)');
|
||||
}
|
||||
|
||||
console.log('\nDone.');
|
||||
process.exit(0);
|
||||
@@ -95,8 +95,10 @@ directorAgent:
|
||||
externalAgents: []
|
||||
|
||||
passkey:
|
||||
mode: localhost
|
||||
rpId: localhost
|
||||
mode: https
|
||||
rpId: a2a-demo.mcplet.ai
|
||||
fido2ServerUrl: https://fido2.epk.amipro.me
|
||||
apiPort: 8443
|
||||
|
||||
dashboard:
|
||||
port: 4000
|
||||
|
||||
Reference in New Issue
Block a user