CLI Device Flow Authentication

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add OAuth device flow so dh login opens a browser, user clicks Authorize, and the CLI receives a long-lived token — no manual copy-paste.

Architecture: Three moving pieces: (1) a server-side device code table for the short-lived handshake and a token table for long-lived CLI credentials; (2) three Next.js API/page routes for initiate → authorize → poll; (3) login/logout CLI commands that drive the flow and persist the token to ~/.config/datahub/credentials. The checkAuth helper is upgraded from a shared-secret boolean check to an async per-user token lookup.

Tech Stack: Next.js 14 App Router, Prisma (PostgreSQL), Go 1.22 + Cobra, Node crypto (SHA-256), crypto/rand (Go)


Context

  • CLI config: cli/cmd/config.go — currently reads three env vars only
  • CLI API client: cli/cmd/api.go — sends Authorization: Bearer <token>
  • CLI root: cli/cmd/root.go — registers commands; only has push and delete
  • Server auth: app/api/v1/_auth.tscheckAuth() compares token to AUTH_BEARER_TOKEN env var, returns boolean
  • All five v1 route files call checkAuth(req) as a boolean guard
  • DB schema: prisma/schema.prisma — no per-user API token fields yet
  • Settings page: app/dashboard/settings/page.tsx — shows profile only, no token UI yet

Task 1: DB Schema — device codes and CLI tokens

Files:

  • Modify: prisma/schema.prisma

Add two models and relations to User. The CliDeviceCode holds the short-lived code during the handshake. Once the user authorizes, a raw token is briefly stored in pendingToken so the polling endpoint can return it once, then the record is deleted. The long-lived token hash lives in UserCliToken.

Step 1: Add the two models to prisma/schema.prisma

After the VerificationToken model, add:

model CliDeviceCode {
  id           String   @id @default(cuid())
  code         String   @unique
  userId       String?  @map("user_id")
  pendingToken String?  @map("pending_token")
  expiresAt    DateTime @map("expires_at")
  createdAt    DateTime @default(now()) @map("created_at")
  user         User?    @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@map("cli_device_codes")
}

model UserCliToken {
  id        String   @id @default(cuid())
  userId    String   @map("user_id")
  tokenHash String   @unique @map("token_hash")
  createdAt DateTime @default(now()) @map("created_at")
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
  @@map("user_cli_tokens")
}

On the User model, add two new relation fields (after postAuthors):

  cliDeviceCodes CliDeviceCode[]
  cliTokens      UserCliToken[]

Step 2: Create and run the migration

cd /Users/o/Projects/Datopian/workspace/datahub-next
pnpm prisma migrate dev --name add-cli-auth-tables

Expected: migration created and applied, Prisma client regenerated.

Step 3: Commit

git add prisma/schema.prisma prisma/migrations/
git commit -m "feat(db): add CliDeviceCode and UserCliToken tables for CLI device flow auth"

Task 2: Token utilities library

Files:

  • Create: lib/cli-auth.ts

Pure functions — no DB access — so they're easy to unit-test.

Step 1: Write the failing test

Create lib/cli-auth.test.ts:

import { generateDeviceCode, generateCliToken, hashToken } from "./cli-auth"

describe("generateDeviceCode", () => {
  test("returns 16-char hex string", () => {
    const code = generateDeviceCode()
    expect(code).toMatch(/^[0-9a-f]{16}$/)
  })

  test("returns unique values", () => {
    expect(generateDeviceCode()).not.toBe(generateDeviceCode())
  })
})

describe("generateCliToken", () => {
  test("returns string with dh_ prefix", () => {
    expect(generateCliToken()).toMatch(/^dh_[A-Za-z0-9_-]{32}$/)
  })

  test("returns unique values", () => {
    expect(generateCliToken()).not.toBe(generateCliToken())
  })
})

describe("hashToken", () => {
  test("returns 64-char hex SHA-256 hash", () => {
    expect(hashToken("anything")).toMatch(/^[0-9a-f]{64}$/)
  })

  test("is deterministic", () => {
    expect(hashToken("abc")).toBe(hashToken("abc"))
  })

  test("different inputs produce different hashes", () => {
    expect(hashToken("abc")).not.toBe(hashToken("xyz"))
  })
})

Step 2: Run test to confirm it fails

cd /Users/o/Projects/Datopian/workspace/datahub-next
pnpm jest lib/cli-auth.test.ts

Expected: FAIL — "Cannot find module './cli-auth'"

Step 3: Implement lib/cli-auth.ts

import { createHash, randomBytes } from "crypto"

export function generateDeviceCode(): string {
  return randomBytes(8).toString("hex")
}

export function generateCliToken(): string {
  return "dh_" + randomBytes(24).toString("base64url")
}

export function hashToken(token: string): string {
  return createHash("sha256").update(token).digest("hex")
}

Step 4: Run tests to confirm they pass

pnpm jest lib/cli-auth.test.ts

Expected: PASS — 6 tests

Step 5: Commit

git add lib/cli-auth.ts lib/cli-auth.test.ts
git commit -m "feat(lib): add CLI auth token utilities (generateDeviceCode, generateCliToken, hashToken)"

Task 3: Initiate device flow endpoint

Files:

  • Create: app/api/cli/auth/device/route.ts

Step 1: Create the route

import { NextRequest } from "next/server"
import prisma from "@/server/db"
import { generateDeviceCode } from "@/lib/cli-auth"
import { env } from "@/env.mjs"

export async function POST(req: NextRequest) {
  const code = generateDeviceCode()
  const expiresAt = new Date(Date.now() + 15 * 60 * 1000) // 15 minutes

  await prisma.cliDeviceCode.create({ data: { code, expiresAt } })

  const verificationUri = `${env.NEXTAUTH_URL}/cli/auth/authorize?code=${code}`

  return Response.json({ code, verificationUri })
}

Step 2: Check env.mjs has NEXTAUTH_URL exported

grep -n "NEXTAUTH_URL" /Users/o/Projects/Datopian/workspace/datahub-next/env.mjs

If not present, add it to the server schema in env.mjs. If it is present, move on.

Step 3: Commit

git add app/api/cli/auth/device/route.ts
git commit -m "feat(api): POST /api/cli/auth/device — initiate CLI device flow"

Task 4: Polling endpoint

Files:

  • Create: app/api/cli/auth/device/[code]/route.ts

CLI polls this every 3 seconds. Returns pending, expired, or complete with the raw token. On complete, the device code record is deleted (the token is now in UserCliToken).

Step 1: Create the route

import { NextRequest } from "next/server"
import prisma from "@/server/db"

export async function GET(
  req: NextRequest,
  { params }: { params: { code: string } }
) {
  const record = await prisma.cliDeviceCode.findUnique({
    where: { code: params.code },
  })

  if (!record) {
    return Response.json({ status: "expired" })
  }

  if (record.expiresAt < new Date()) {
    await prisma.cliDeviceCode.delete({ where: { code: params.code } })
    return Response.json({ status: "expired" })
  }

  if (!record.pendingToken) {
    return Response.json({ status: "pending" })
  }

  // Return token once and clean up
  const token = record.pendingToken
  await prisma.cliDeviceCode.delete({ where: { code: params.code } })

  return Response.json({ status: "complete", token })
}

Step 2: Commit

git add app/api/cli/auth/device/[code]/route.ts
git commit -m "feat(api): GET /api/cli/auth/device/[code] — device flow polling endpoint"

Task 5: Browser authorization page

Files:

  • Create: app/cli/auth/authorize/page.tsx
  • Create: app/api/cli/auth/authorize/route.ts

The page shows the code so the user can visually confirm it matches what the CLI printed. The POST route creates the UserCliToken and stores the raw token in pendingToken so the polling endpoint can retrieve it.

Step 1: Create the POST route

Create app/api/cli/auth/authorize/route.ts:

import { NextRequest } from "next/server"
import prisma from "@/server/db"
import { getSession } from "@/server/auth"
import { generateCliToken, hashToken } from "@/lib/cli-auth"

export async function POST(req: NextRequest) {
  const session = await getSession()
  if (!session?.user?.id) {
    return Response.json({ error: "Unauthorized" }, { status: 401 })
  }

  const { code } = await req.json()
  if (!code) {
    return Response.json({ error: "Missing code" }, { status: 400 })
  }

  const record = await prisma.cliDeviceCode.findUnique({ where: { code } })
  if (!record || record.expiresAt < new Date()) {
    return Response.json({ error: "Invalid or expired code" }, { status: 400 })
  }

  const rawToken = generateCliToken()
  const tokenHash = hashToken(rawToken)

  await prisma.$transaction([
    prisma.userCliToken.create({
      data: { userId: session.user.id, tokenHash },
    }),
    prisma.cliDeviceCode.update({
      where: { code },
      data: { userId: session.user.id, pendingToken: rawToken },
    }),
  ])

  return Response.json({ ok: true })
}

Step 2: Create the page

Create app/cli/auth/authorize/page.tsx:

import { redirect } from "next/navigation"
import { getSession } from "@/server/auth"
import AuthorizeForm from "./authorize-form"

interface Props {
  searchParams: { code?: string }
}

export default async function AuthorizePage({ searchParams }: Props) {
  const session = await getSession()
  if (!session) {
    const callbackUrl = encodeURIComponent(
      `/cli/auth/authorize?code=${searchParams.code ?? ""}`
    )
    redirect(`/login?callbackUrl=${callbackUrl}`)
  }

  if (!searchParams.code) {
    return (
      <div className="flex min-h-screen items-center justify-center">
        <p className="text-red-500">Missing authorization code.</p>
      </div>
    )
  }

  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="w-full max-w-sm rounded-lg border border-gray-200 p-8">
        <h1 className="text-xl font-semibold">Authorize DataHub CLI</h1>
        <p className="mt-2 text-sm text-gray-600">
          The DataHub CLI is requesting access to your account.
        </p>
        <p className="mt-4 text-sm">
          Signed in as <strong>{session.user?.name}</strong>
        </p>
        <AuthorizeForm code={searchParams.code} />
      </div>
    </div>
  )
}

Step 3: Create the client form component

Create app/cli/auth/authorize/authorize-form.tsx:

"use client"

import { useState } from "react"

export default function AuthorizeForm({ code }: { code: string }) {
  const [status, setStatus] = useState<"idle" | "loading" | "done" | "error">("idle")

  async function handleAuthorize() {
    setStatus("loading")
    const res = await fetch("/api/cli/auth/authorize", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ code }),
    })
    setStatus(res.ok ? "done" : "error")
  }

  if (status === "done") {
    return (
      <p className="mt-6 text-green-600 font-medium">
        ✓ Authorized! You can close this tab and return to the terminal.
      </p>
    )
  }

  return (
    <div className="mt-6 space-y-3">
      {status === "error" && (
        <p className="text-sm text-red-500">
          Authorization failed. The code may have expired. Run <code>dh login</code> again.
        </p>
      )}
      <button
        onClick={handleAuthorize}
        disabled={status === "loading"}
        className="w-full rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50"
      >
        {status === "loading" ? "Authorizing…" : "Authorize"}
      </button>
    </div>
  )
}

Step 4: Commit

git add app/api/cli/auth/authorize/route.ts app/cli/auth/authorize/page.tsx app/cli/auth/authorize/authorize-form.tsx
git commit -m "feat: CLI device flow browser authorization page and confirm endpoint"

Task 6: Upgrade checkAuth to support per-user tokens

Files:

  • Modify: app/api/v1/_auth.ts
  • Modify: app/api/v1/publications/route.ts
  • Modify: app/api/v1/publications/[slug]/datasets/route.ts
  • Modify: app/api/v1/publications/[slug]/datasets/[name]/route.ts
  • Modify: app/api/v1/publications/[slug]/datasets/[name]/files/route.ts
  • Modify: app/api/v1/publications/[slug]/datasets/[name]/files/[...path]/route.ts

checkAuth becomes async and returns { ok: boolean; userId: string | null } instead of boolean. The shared AUTH_BEARER_TOKEN still works (userId is null in that case — backward compat). Per-user CLI tokens are looked up by SHA-256 hash.

Step 1: Rewrite app/api/v1/_auth.ts

import { NextRequest } from "next/server"
import { createHash } from "crypto"
import prisma from "@/server/db"

export interface AuthResult {
  ok: boolean
  userId: string | null
}

export async function checkAuth(req: NextRequest): Promise<AuthResult> {
  const authHeader = req.headers.get("authorization")
  if (!authHeader?.startsWith("Bearer ")) return { ok: false, userId: null }
  const token = authHeader.slice(7)

  // Shared admin token (backward compat)
  if (process.env.AUTH_BEARER_TOKEN && token === process.env.AUTH_BEARER_TOKEN) {
    return { ok: true, userId: null }
  }

  // Per-user CLI token
  const tokenHash = createHash("sha256").update(token).digest("hex")
  const cliToken = await prisma.userCliToken.findUnique({ where: { tokenHash } })
  if (cliToken) return { ok: true, userId: cliToken.userId }

  return { ok: false, userId: null }
}

export const unauthorized = () =>
  Response.json({ error: "Unauthorized" }, { status: 401 })

export const badRequest = (message: string) =>
  Response.json({ error: message }, { status: 400 })

export const notFound = (message = "Not found") =>
  Response.json({ error: message }, { status: 404 })

Step 2: Update all five route files

In each of the five route files, change:

if (!checkAuth(req)) return unauthorized()

to:

const auth = await checkAuth(req)
if (!auth.ok) return unauthorized()

The five files to update:

  1. app/api/v1/publications/route.ts
  2. app/api/v1/publications/[slug]/datasets/route.ts
  3. app/api/v1/publications/[slug]/datasets/[name]/route.ts
  4. app/api/v1/publications/[slug]/datasets/[name]/files/route.ts
  5. app/api/v1/publications/[slug]/datasets/[name]/files/[...path]/route.ts

Step 3: Run TypeScript check

cd /Users/o/Projects/Datopian/workspace/datahub-next
pnpm tsc --noEmit

Expected: no errors.

Step 4: Commit

git add app/api/v1/
git commit -m "feat(api): upgrade checkAuth to async per-user token lookup, keep shared token for backward compat"

Task 7: CLI — credentials file helpers

Files:

  • Create: cli/cmd/credentials.go

Reads and writes ~/.config/datahub/credentials as JSON. Used by login, logout, and loadConfig.

Step 1: Create cli/cmd/credentials.go

package cmd

import (
	"encoding/json"
	"os"
	"path/filepath"
)

type Credentials struct {
	APIURL      string `json:"apiUrl"`
	Token       string `json:"token"`
	Publication string `json:"publication"`
}

func credentialsPath() (string, error) {
	dir, err := os.UserConfigDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(dir, "datahub", "credentials"), nil
}

func loadCredentials() (*Credentials, error) {
	path, err := credentialsPath()
	if err != nil {
		return nil, err
	}
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, err
	}
	var creds Credentials
	if err := json.Unmarshal(data, &creds); err != nil {
		return nil, err
	}
	return &creds, nil
}

func saveCredentials(creds Credentials) error {
	path, err := credentialsPath()
	if err != nil {
		return err
	}
	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
		return err
	}
	data, err := json.MarshalIndent(creds, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(path, data, 0600)
}

func deleteCredentials() error {
	path, err := credentialsPath()
	if err != nil {
		return err
	}
	err = os.Remove(path)
	if os.IsNotExist(err) {
		return nil
	}
	return err
}

Step 2: Build to confirm it compiles

cd /Users/o/Projects/Datopian/workspace/datahub-next/cli
go build ./...

Expected: no errors.

Step 3: Commit

git add cli/cmd/credentials.go
git commit -m "feat(cli): add credentials file helpers (load/save/delete ~/.config/datahub/credentials)"

Task 8: Update CLI config to fall back to credentials file

Files:

  • Modify: cli/cmd/config.go

Priority: env vars → credentials file → error.

Step 1: Rewrite loadConfig in cli/cmd/config.go

package cmd

import (
	"fmt"
	"os"
	"strings"
)

type Config struct {
	APIURL      string
	APIToken    string
	Publication string
}

func loadConfig() (Config, error) {
	apiURL := os.Getenv("DATAHUB_API_URL")
	apiToken := os.Getenv("DATAHUB_API_TOKEN")
	publication := os.Getenv("DATAHUB_PUBLICATION")

	// Fall back to credentials file if any env var is missing
	if apiURL == "" || apiToken == "" || publication == "" {
		creds, err := loadCredentials()
		if err != nil {
			return Config{}, fmt.Errorf("reading credentials: %w", err)
		}
		if creds != nil {
			if apiURL == "" {
				apiURL = creds.APIURL
			}
			if apiToken == "" {
				apiToken = creds.Token
			}
			if publication == "" {
				publication = creds.Publication
			}
		}
	}

	if apiURL == "" {
		return Config{}, fmt.Errorf("DATAHUB_API_URL is not set (run 'dh login' or set the environment variable)")
	}
	if apiToken == "" {
		return Config{}, fmt.Errorf("DATAHUB_API_TOKEN is not set (run 'dh login' or set the environment variable)")
	}
	if publication == "" {
		return Config{}, fmt.Errorf("DATAHUB_PUBLICATION is not set (run 'dh login' or set the environment variable)")
	}

	return Config{
		APIURL:      strings.TrimRight(apiURL, "/"),
		APIToken:    apiToken,
		Publication: publication,
	}, nil
}

Step 2: Build to confirm it compiles

cd /Users/o/Projects/Datopian/workspace/datahub-next/cli
go build ./...

Step 3: Commit

git add cli/cmd/config.go
git commit -m "feat(cli): loadConfig falls back to credentials file when env vars unset"

Task 9: CLI login command

Files:

  • Create: cli/cmd/login.go

Drives the full device flow: initiate → open browser → poll → save credentials.

Step 1: Create cli/cmd/login.go

package cmd

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"runtime"
	"strings"
	"time"

	"github.com/spf13/cobra"
)

var loginCmd = &cobra.Command{
	Use:   "login",
	Short: "Authenticate the DataHub CLI via your browser",
	RunE:  runLogin,
}

var loginAPIURL string

func init() {
	loginCmd.Flags().StringVar(&loginAPIURL, "api-url", "https://datahub.io", "DataHub server URL")
}

func runLogin(cmd *cobra.Command, args []string) error {
	apiURL := strings.TrimRight(loginAPIURL, "/")

	// 1. Initiate device flow
	resp, err := http.Post(apiURL+"/api/cli/auth/device", "application/json", nil)
	if err != nil {
		return fmt.Errorf("connecting to %s: %w", apiURL, err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		return fmt.Errorf("server error %d: %s", resp.StatusCode, body)
	}

	var initResp struct {
		Code            string `json:"code"`
		VerificationUri string `json:"verificationUri"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&initResp); err != nil {
		return fmt.Errorf("decoding server response: %w", err)
	}

	// 2. Prompt user
	fmt.Printf("\nOpen this URL in your browser to authorize the DataHub CLI:\n\n  %s\n\n", initResp.VerificationUri)
	fmt.Println("Waiting for authorization...")

	openBrowser(initResp.VerificationUri)

	// 3. Poll until complete or expired
	pollURL := fmt.Sprintf("%s/api/cli/auth/device/%s", apiURL, initResp.Code)
	token, err := pollForToken(pollURL)
	if err != nil {
		return err
	}

	// 4. Ask for publication slug
	var publication string
	fmt.Print("\nEnter your publication slug: ")
	fmt.Scanln(&publication)
	publication = strings.TrimSpace(publication)
	if publication == "" {
		return fmt.Errorf("publication slug is required")
	}

	// 5. Save credentials
	if err := saveCredentials(Credentials{
		APIURL:      apiURL,
		Token:       token,
		Publication: publication,
	}); err != nil {
		return fmt.Errorf("saving credentials: %w", err)
	}

	fmt.Println("\n✓ Authenticated! Credentials saved to ~/.config/datahub/credentials")
	return nil
}

func pollForToken(pollURL string) (string, error) {
	deadline := time.Now().Add(15 * time.Minute)
	for time.Now().Before(deadline) {
		time.Sleep(3 * time.Second)

		resp, err := http.Get(pollURL)
		if err != nil {
			continue
		}
		var result struct {
			Status string `json:"status"`
			Token  string `json:"token"`
		}
		json.NewDecoder(resp.Body).Decode(&result)
		resp.Body.Close()

		switch result.Status {
		case "complete":
			return result.Token, nil
		case "expired":
			return "", fmt.Errorf("authorization timed out. Run 'dh login' again")
		}
		// "pending" — keep polling
	}
	return "", fmt.Errorf("authorization timed out")
}

func openBrowser(url string) {
	var cmd string
	var args []string
	switch runtime.GOOS {
	case "darwin":
		cmd, args = "open", []string{url}
	case "windows":
		cmd, args = "cmd", []string{"/c", "start", url}
	default:
		cmd, args = "xdg-open", []string{url}
	}
	exec.Command(cmd, args...).Start() //nolint:errcheck — best-effort
}

Step 2: Build to confirm it compiles

cd /Users/o/Projects/Datopian/workspace/datahub-next/cli
go build ./...

Step 3: Commit

git add cli/cmd/login.go
git commit -m "feat(cli): add 'dh login' command with device flow browser authorization"

Task 10: CLI logout command

Files:

  • Create: cli/cmd/logout.go

Step 1: Create cli/cmd/logout.go

package cmd

import (
	"fmt"

	"github.com/spf13/cobra"
)

var logoutCmd = &cobra.Command{
	Use:   "logout",
	Short: "Remove stored DataHub CLI credentials",
	RunE:  runLogout,
}

func runLogout(cmd *cobra.Command, args []string) error {
	if err := deleteCredentials(); err != nil {
		return fmt.Errorf("removing credentials: %w", err)
	}
	fmt.Println("✓ Logged out. Credentials removed.")
	return nil
}

Step 2: Register both commands in cli/cmd/root.go

In init(), add:

rootCmd.AddCommand(loginCmd)
rootCmd.AddCommand(logoutCmd)

Step 3: Build and smoke-test

cd /Users/o/Projects/Datopian/workspace/datahub-next/cli
go build -o /tmp/dh .
/tmp/dh --help
/tmp/dh login --help
/tmp/dh logout --help

Expected: both commands appear in help output.

Step 4: Commit

git add cli/cmd/logout.go cli/cmd/root.go
git commit -m "feat(cli): add 'dh logout' command and register login/logout in root"

Manual end-to-end test

With a local dev server running (pnpm dev):

# Run login with local server
/tmp/dh login --api-url http://localhost:3000

# Expected output:
# Open this URL in your browser to authorize the DataHub CLI:
#   http://localhost:3000/cli/auth/authorize?code=<hex>
# Waiting for authorization...

# In browser: authorize → ✓ Authorized!
# In terminal: ✓ Authenticated! Credentials saved to ~/.config/datahub/credentials

# Confirm credentials file written
cat ~/.config/datahub/credentials

# Test that push now works without env vars
/tmp/dh push ./some-dataset --name test-dataset

# Logout
/tmp/dh logout
# Expected: ✓ Logged out. Credentials removed.

# Confirm push now fails cleanly
/tmp/dh push ./some-dataset --name test-dataset
# Expected: error about missing credentials