Skip to content

Add encrypted OTP flow#506

Open
carsonp6 wants to merge 1 commit into
mainfrom
05-26-add_encrypted_otp_flow
Open

Add encrypted OTP flow#506
carsonp6 wants to merge 1 commit into
mainfrom
05-26-add_encrypted_otp_flow

Conversation

@carsonp6
Copy link
Copy Markdown
Contributor

@carsonp6 carsonp6 commented May 26, 2026

TL;DR

Introduces a secure V3 HPKE-based EMAIL_OTP verification flow and deprecates the legacy plaintext OTP flow.

What changed?

POST /auth/credentials (registration)

  • The 201 response for EMAIL_OTP credentials now includes otpEncryptionTargetBundle — a one-time HPKE target bundle the client uses to encrypt the OTP attempt before sending it to the server.

POST /auth/credentials/{id}/challenge (re-issue)

  • The EMAIL_OTP response now returns a fresh otpEncryptionTargetBundle alongside the AuthMethod, replacing the previous description that said there was no challenge body to surface.

POST /auth/credentials/{id}/verify (verification)

  • Adds a new V3 secure flow for EMAIL_OTP: the client submits an encryptedOtpBundle (HPKE-encrypted payload containing the TEK public key and OTP code attempt). The server responds with 202 carrying a payloadToSign (verificationToken). The client signs the token with the TEK private key and retries with Grid-Wallet-Signature and Request-Id headers to receive the issued AuthSession. The TEK public key becomes the session API key on completion.
  • The previous EMAIL_OTP flow (plaintext otp + clientPublicKey) is now marked deprecated and will be removed in a future release.
  • Adds the Grid-Wallet-Signature request header, required on the signed retry leg of the V3 EMAIL_OTP flow.
  • Updates Request-Id header description to cover both the EMAIL_OTP signed retry and PASSKEY assertion correlation use cases.
  • Adds a 202 response schema (AuthSignedRequestChallenge) to the verify endpoint.
  • Updates 401 error conditions to cover EMAIL_OTP signed retry failures (missing/malformed signature, key mismatch, expired challenge).
  • Sandbox behavior documented: V3 flow runs real HPKE end-to-end; the only shortcut is the magic OTP code "000000".

Schema changes

  • AuthMethodResponse: adds otpEncryptionTargetBundle property.
  • AuthSignedRequestChallenge: extended to cover the EMAIL_OTP verify retry use case; documents that the TEK keypair (not the session API keypair) is used to sign the stamp for this operation.
  • EmailOtpCredentialVerifyRequestFields: otp and clientPublicKey marked deprecated; encryptedOtpBundle added; otp and clientPublicKey removed from required.

How to test?

V3 flow:

  1. Register an EMAIL_OTP credential via POST /auth/credentials and capture otpEncryptionTargetBundle from the response.
  2. Generate an ephemeral P-256 TEK keypair on the client.
  3. HPKE-encrypt {clientPublicKey, otpCodeAttempt} under otpEncryptionTargetBundle to produce encryptedOtpBundle.
  4. Submit POST /auth/credentials/{id}/verify with encryptedOtpBundle and expect a 202 response containing payloadToSign and requestId.
  5. Sign payloadToSign with the TEK private key and resubmit with Grid-Wallet-Signature and Request-Id headers; expect a 200 AuthSession.
  6. In sandbox, use OTP code "000000" as the magic value.

Legacy flow (still functional, deprecated):

  1. Submit POST /auth/credentials/{id}/verify with plaintext otp and clientPublicKey; expect a 200 AuthSession with encryptedSessionSigningKey.

Why make this change?

The legacy EMAIL_OTP flow transmits the plaintext OTP code to the server, creating an unnecessary exposure surface. The V3 flow uses HPKE to encrypt the OTP code and the client's public key together so the plaintext code never transits the server. The TEK keypair generated by the client for encryption also becomes the session API key, binding authentication and session establishment into a single cryptographic operation.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 26, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
grid-flow-builder Ready Ready Preview, Comment May 26, 2026 11:17pm

Request Review

Copy link
Copy Markdown
Contributor Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 26, 2026

✱ Stainless preview builds for grid

This PR will update the grid SDKs with the following commit messages.

cli

feat: Add encrypted OTP flow

csharp

feat: Add encrypted OTP flow

go

feat: Add encrypted OTP flow

kotlin

feat: Add encrypted OTP flow

openapi

feat: Add encrypted OTP flow

php

feat: Add encrypted OTP flow

python

feat: Add encrypted OTP flow

ruby

feat: Add encrypted OTP flow

typescript

feat: Add encrypted OTP flow

Edit this comment to update them. They will appear in their respective SDK's changelogs.

grid-openapi studio · code · diff

Your SDK build had at least one "error" diagnostic, but this did not represent a regression.
generate ❗

grid-ruby studio · code · diff

Your SDK build had at least one "error" diagnostic, but this did not represent a regression.
generate ❗build ✅ (prev: build ⏭️) → lint ❗test ✅

grid-go studio · code · diff

Your SDK build had at least one "error" diagnostic, but this did not represent a regression.
generate ❗build ✅ (prev: build ⏭️) → lint ❗test ❗

go get github.com/stainless-sdks/grid-go@9fdd7dffb2d600cd4cd3867caaf7559de3f0ccec
⚠️ grid-typescript studio · code · diff

Your SDK build had a failure in the build CI job, which is a regression from the base state.
generate ❗build ❗ (prev: build ⏭️) → lint ❗ (prev: lint ⏭️) → test ❗

grid-kotlin studio · code · diff

Your SDK build had at least one "error" diagnostic, but this did not represent a regression.
generate ❗build ✅ (prev: build ⏭️) → lint ✅ (prev: lint ⏭️) → test ❗

grid-python studio · code · diff

Your SDK build had at least one "error" diagnostic, but this did not represent a regression.
generate ❗build ✅ (prev: build ⏭️) → lint ✅ (prev: lint ⏭️) → test ✅

pip install https://pkg.stainless.com/s/grid-python/832f249ff74a86c2865db94ed45be897c638c857/grid-0.0.1-py3-none-any.whl
⚠️ grid-csharp studio · code · diff

Your SDK build had a failure in the build CI job, which is a regression from the base state.
generate ❗build ❗ (prev: build ⏭️) → lint ✅ (prev: lint ⏭️) → test ❗

New diagnostics (1 warning, 1 note)
⚠️ Schema/CannotInferName: Placeholder name generated for schema.
💡 Name/Renamed: 406 names were renamed due to language constraints, so fallback names will be used instead.
⚠️ grid-php studio · code · diff

Your SDK build had a failure in the lint CI job, which is a regression from the base state.
generate ❗lint ❗ (prev: lint ✅) → test ✅

grid-cli studio · code · diff

Your SDK build had at least one "error" diagnostic, but this did not represent a regression.
generate ❗build ❗lint ❗test ❗


This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push.
If you push custom code to the preview branch, re-run this workflow to update the comment.
Last updated: 2026-05-26 23:22:40 UTC

@carsonp6 carsonp6 marked this pull request as ready for review May 26, 2026 21:33
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 26, 2026

Greptile Summary

This PR introduces a V3 HPKE-based EMAIL_OTP verification flow in which the OTP code and client public key are encrypted client-side before being sent to the server, so plaintext credentials never transit the wire. The legacy plaintext flow (otp + clientPublicKey) is preserved but marked deprecated across all three auth endpoints.

  • New V3 two-leg flow: first leg submits encryptedOtpBundle and receives a 202 with a verificationToken; client signs with the TEK private key and retries with Grid-Wallet-Signature + Request-Id to receive the AuthSession. The TEK public key becomes the session API key.
  • Schema and description updates: AuthMethodResponse gains otpEncryptionTargetBundle; AuthSignedRequestChallenge is extended to cover the EMAIL_OTP verify retry; EmailOtpCredentialVerifyRequestFields deprecates otp/clientPublicKey and introduces encryptedOtpBundle.
  • Missing schema constraint: EmailOtpCredentialVerifyRequestFields leaves only type in required with no oneOf/not guard — the mutual exclusivity between encryptedOtpBundle and otp+clientPublicKey is documented in prose but not machine-enforceable.

Confidence Score: 3/5

The new V3 flow is clearly described and the documentation is thorough, but the EmailOtpCredentialVerifyRequestFields schema omits the structural constraint that enforces exactly one set of fields, leaving the contract incomplete for any tooling that relies on schema validation.

The core auth flow description, response codes, header additions, and deprecation markings are accurate and internally consistent. The gap is in EmailOtpCredentialVerifyRequestFields: removing otp and clientPublicKey from required without adding a oneOf or not constraint means the schema accepts {type: 'EMAIL_OTP'} alone or both field sets simultaneously — conditions the server would reject at runtime but that no schema validator or generated SDK will flag.

openapi/components/schemas/auth/EmailOtpCredentialVerifyRequestFields.yaml needs a oneOf (or equivalent) to machine-enforce the mutual exclusivity between the V3 and legacy field sets.

Important Files Changed

Filename Overview
openapi/components/schemas/auth/EmailOtpCredentialVerifyRequestFields.yaml Removes otp and clientPublicKey from required and adds encryptedOtpBundle; only type remains required, leaving no schema enforcement that exactly one of the two field sets is present.
openapi/paths/auth/auth_credentials_{id}_verify.yaml Adds V3 two-leg EMAIL_OTP flow with Grid-Wallet-Signature header, a new 202 response schema, and updated 401 conditions; thorough and accurate, though the payloadToSign example has a non-base64url .signature placeholder.
openapi/components/schemas/auth/AuthMethodResponse.yaml Adds optional otpEncryptionTargetBundle string property to the allOf, correctly scoped to EMAIL_OTP with clear one-time-use and re-issue instructions.
openapi/components/schemas/auth/AuthSignedRequestChallenge.yaml Description-only extension to cover the EMAIL_OTP verify retry use case and TEK keypair signing instructions; schema structure is unchanged and correct.
openapi/paths/auth/auth_credentials.yaml Adds otpEncryptionTargetBundle to the 201 EMAIL_OTP response description and example; straightforward and accurate.
openapi/paths/auth/auth_credentials_{id}_challenge.yaml Updates the EMAIL_OTP challenge description and example to surface otpEncryptionTargetBundle on re-issue; clear and consistent with the registration path.
openapi.yaml Generated bundle — reflects all source changes from openapi/; no concerns specific to this file.
mintlify/openapi.yaml Mintlify copy of the generated bundle; mirrors openapi.yaml as expected after make build.

Sequence Diagram

sequenceDiagram
    participant C as Client
    participant S as Grid Server

    Note over C,S: Registration
    C->>S: POST /auth/credentials (EMAIL_OTP)
    S-->>C: 201 AuthMethod + otpEncryptionTargetBundle

    Note over C,S: V3 Secure OTP Flow
    C->>C: Generate ephemeral P-256 TEK keypair
    C->>C: "HPKE-encrypt {clientPublicKey, otpCodeAttempt} → encryptedOtpBundle"
    C->>S: "POST /auth/credentials/{id}/verify {type, encryptedOtpBundle}"
    S-->>C: "202 {payloadToSign, requestId, expiresAt}"
    C->>C: Sign payloadToSign with TEK private key → stamp
    C->>S: "POST /auth/credentials/{id}/verify + Grid-Wallet-Signature + Request-Id"
    S-->>C: "200 AuthSession (TEK public key = session API key)"

    Note over C,S: Legacy Flow (deprecated)
    C->>S: "POST /auth/credentials/{id}/verify {type, otp, clientPublicKey}"
    S-->>C: 200 AuthSession + encryptedSessionSigningKey
Loading

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
openapi/components/schemas/auth/EmailOtpCredentialVerifyRequestFields.yaml:1-6
**Schema gap: "provide exactly one set" is undeclared**

The schema declares only `type` as `required`, so a request body of `{"type":"EMAIL_OTP"}` (no OTP fields at all) passes OpenAPI validation and only fails at the server. Likewise, a body containing both `encryptedOtpBundle` and `otp`+`clientPublicKey` (the mutually-exclusive pair) also passes schema validation. Client SDK generators, mock servers, and contract-testing tools all rely on `required` arrays and `oneOf`/`not` constraints — prose-only documentation of the mutual-exclusivity rule won't be picked up by any of those.

### Issue 2 of 2
openapi/paths/auth/auth_credentials_{id}_verify.yaml:172-175
**`payloadToSign` example contains a non-base64url JWT signature segment**

The third JWT segment of the `payloadToSign` example value is the literal string `signature` rather than a base64url-encoded ECDSA signature. Developers who try to programmatically inspect or validate this example (e.g., via `jwt.io`, client-side JWT parsing, or automated contract tests) will get a parse error. A realistic placeholder — even a dummy but structurally valid base64url string — would avoid confusion.

Reviews (1): Last reviewed commit: "Add encrypted OTP flow" | Re-trigger Greptile

Comment on lines +1 to +6
type: object
required:
- type
- otp
- clientPublicKey
description: >-
Verify an email-OTP credential. Two request shapes are accepted; provide
exactly one set.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Schema gap: "provide exactly one set" is undeclared

The schema declares only type as required, so a request body of {"type":"EMAIL_OTP"} (no OTP fields at all) passes OpenAPI validation and only fails at the server. Likewise, a body containing both encryptedOtpBundle and otp+clientPublicKey (the mutually-exclusive pair) also passes schema validation. Client SDK generators, mock servers, and contract-testing tools all rely on required arrays and oneOf/not constraints — prose-only documentation of the mutual-exclusivity rule won't be picked up by any of those.

Prompt To Fix With AI
This is a comment left during a code review.
Path: openapi/components/schemas/auth/EmailOtpCredentialVerifyRequestFields.yaml
Line: 1-6

Comment:
**Schema gap: "provide exactly one set" is undeclared**

The schema declares only `type` as `required`, so a request body of `{"type":"EMAIL_OTP"}` (no OTP fields at all) passes OpenAPI validation and only fails at the server. Likewise, a body containing both `encryptedOtpBundle` and `otp`+`clientPublicKey` (the mutually-exclusive pair) also passes schema validation. Client SDK generators, mock servers, and contract-testing tools all rely on `required` arrays and `oneOf`/`not` constraints — prose-only documentation of the mutual-exclusivity rule won't be picked up by any of those.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Comment on lines +172 to +175
type: EMAIL_OTP
payloadToSign: eyJhbGciOiJFUzI1NiIsImtpZCI6InR1cm5rZXkifQ.eyJzdWIiOiJUWnk2NkVPa1RGYTd2NkpXZ0VxaVgyZGFXOENXc2pMQzVDVU9aRUlGY3hzIiwiaWF0IjoxNzc5NDA3MjIxLCJleHAiOjE3Nzk0MTA4MjF9.signature
requestId: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21
expiresAt: '2026-04-08T15:35:00Z'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 payloadToSign example contains a non-base64url JWT signature segment

The third JWT segment of the payloadToSign example value is the literal string signature rather than a base64url-encoded ECDSA signature. Developers who try to programmatically inspect or validate this example (e.g., via jwt.io, client-side JWT parsing, or automated contract tests) will get a parse error. A realistic placeholder — even a dummy but structurally valid base64url string — would avoid confusion.

Prompt To Fix With AI
This is a comment left during a code review.
Path: openapi/paths/auth/auth_credentials_{id}_verify.yaml
Line: 172-175

Comment:
**`payloadToSign` example contains a non-base64url JWT signature segment**

The third JWT segment of the `payloadToSign` example value is the literal string `signature` rather than a base64url-encoded ECDSA signature. Developers who try to programmatically inspect or validate this example (e.g., via `jwt.io`, client-side JWT parsing, or automated contract tests) will get a parse error. A realistic placeholder — even a dummy but structurally valid base64url string — would avoid confusion.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant