diff --git a/internal/client/client.go b/internal/client/client.go
index aadc5a0..fc6df78 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -2,33 +2,82 @@ package client
 
 import (
 	"context"
+	_ "embed"
+	"encoding/json"
 	"github.com/google/uuid"
+	"github.com/loveuer/nf"
+	"github.com/loveuer/nf/nft/log"
+	"github.com/loveuer/nf/nft/resp"
 	"golang.org/x/oauth2"
-	"golang.org/x/oauth2/authhandler"
+	"net/http"
+	"uauth/internal/tool"
 )
 
+//go:embed login.html
+var page string
+
 var (
-	State = uuid.New().String()[:8]
+	config = oauth2.Config{
+		ClientID:     "test",
+		ClientSecret: "test",
+		Endpoint: oauth2.Endpoint{
+			AuthURL:  "http://localhost:8080/oauth/v2/authorize",
+			TokenURL: "http://localhost:8080/oauth/v2/token",
+		},
+		RedirectURL: "http://localhost:18080/oauth/v2/redirect",
+		Scopes:      []string{"test"},
+	}
+	state = uuid.New().String()[:8]
 )
 
 func Run(ctx context.Context) error {
-	oauth2.NewClient(ctx, authhandler.TokenSource(
-		ctx,
-		&oauth2.Config{
-			ClientID:     "",
-			ClientSecret: "",
-			Endpoint: oauth2.Endpoint{
-				AuthURL:       "",
-				DeviceAuthURL: "",
-				TokenURL:      "",
-				AuthStyle:     0,
-			},
-			RedirectURL: "",
-			Scopes:      nil,
-		},
-		State,
-		func(authCodeURL string) (code string, state string, err error) {
+	app := nf.New()
+	app.Get("/login", handleLogin)
+	app.Get("/oauth/v2/redirect", handleRedirect)
 
-		},
-	))
+	go func() {
+		<-ctx.Done()
+		_ = app.Shutdown(tool.Timeout(2))
+	}()
+
+	return app.Run(":18080")
+}
+
+func handleLogin(c *nf.Ctx) error {
+	if c.Query("oauth") != "" {
+		return c.Redirect(config.AuthCodeURL(state), http.StatusFound)
+	}
+
+	return c.HTML(page)
+}
+
+func handleRedirect(c *nf.Ctx) error {
+	type Req struct {
+		State string `query:"state"`
+		Code  string `query:"code"`
+	}
+
+	var (
+		err   error
+		req   = new(Req)
+		token *oauth2.Token
+	)
+
+	if err = c.QueryParser(req); err != nil {
+		return resp.Resp400(c, err.Error())
+	}
+
+	if req.State != state {
+		return resp.Resp400(c, "invalid state")
+	}
+
+	if token, err = config.Exchange(c.Context(), req.Code); err != nil {
+		log.Error("[C] oauth config exchange err: %s", err.Error())
+		return resp.Resp500(c, err.Error())
+	}
+
+	bs, _ := json.Marshal(token)
+	log.Info("[C] oauth finally token =\n%s", string(bs))
+
+	return resp.Resp200(c, token)
 }
diff --git a/internal/client/login.html b/internal/client/login.html
index 0dc46b3..bb57e1d 100644
--- a/internal/client/login.html
+++ b/internal/client/login.html
@@ -45,7 +45,7 @@
                 type="submit"
                 value="登录"
         />
-        <a href="http://localhost:8080/api/oauth/v2/login?client_id=test&client_secret=test&scope=test1,test2&redirect_uri=http://localhost:9119/api/login/callback">使用 Pro 账号登录</a>
+        <a href="/login?oauth=true">使用 OAuth V2 账号登录</a>
     </form>
 </div>
 </body>
diff --git a/internal/cmd/client.go b/internal/cmd/client.go
new file mode 100644
index 0000000..b21aeb3
--- /dev/null
+++ b/internal/cmd/client.go
@@ -0,0 +1,16 @@
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+	"uauth/internal/client"
+)
+
+func initClient() *cobra.Command {
+	return &cobra.Command{
+		Use:   "client",
+		Short: "Run the client",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return client.Run(cmd.Context())
+		},
+	}
+}
diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go
index 9314c39..ad0a28a 100644
--- a/internal/cmd/cmd.go
+++ b/internal/cmd/cmd.go
@@ -26,5 +26,6 @@ func init() {
 
 	Command.AddCommand(
 		initServe(),
+		initClient(),
 	)
 }
diff --git a/internal/cmd/serve.go b/internal/cmd/serve.go
index 98bff22..7a5641a 100644
--- a/internal/cmd/serve.go
+++ b/internal/cmd/serve.go
@@ -23,7 +23,7 @@ func initServe() *cobra.Command {
 	}
 
 	svc.Flags().StringVar(&opt.Cfg.Svc.Address, "address", "localhost:8080", "listen address")
-	svc.Flags().StringVar(&opt.Cfg.Svc.Prefix, "prefix", "/api/oauth/v2", "api prefix")
+	svc.Flags().StringVar(&opt.Cfg.Svc.Prefix, "prefix", "/oauth/v2", "api prefix")
 	svc.Flags().StringVar(&opt.Cfg.Svc.Cache, "cache", "lru::", "cache uri")
 	svc.Flags().StringVar(&opt.Cfg.Svc.DB, "db", "sqlite::data.sqlite", "database uri")
 
diff --git a/internal/serve/handler/authorize.go b/internal/serve/handler/authorize.go
index f565167..3af0002 100644
--- a/internal/serve/handler/authorize.go
+++ b/internal/serve/handler/authorize.go
@@ -5,6 +5,7 @@ import (
 	"errors"
 	"github.com/google/uuid"
 	"github.com/loveuer/nf"
+	"github.com/loveuer/nf/nft/log"
 	"github.com/loveuer/nf/nft/resp"
 	"gorm.io/gorm"
 	"net/http"
@@ -27,6 +28,7 @@ func Authorize(c *nf.Ctx) error {
 		ResponseType string `query:"response_type"`
 		RedirectURI  string `query:"redirect_uri"`
 		Scope        string `query:"scope"`
+		State        string `query:"state"`
 	}
 
 	var (
@@ -39,7 +41,8 @@ func Authorize(c *nf.Ctx) error {
 	)
 
 	if err = c.QueryParser(req); err != nil {
-		return resp.Resp400(c, err)
+		log.Error("[S] query parser err = %s", err.Error())
+		return c.Status(http.StatusBadRequest).SendString("Invalid request")
 	}
 
 	if req.ResponseType != "code" {