Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
```bash
go build -o auth . # build binary
go vet ./... # lint
go test ./... # run tests
```

No test files exist in this codebase.
Handler tests run against a real CockroachDB: `tu.Setup()` starts an isolated
in-memory `cockroach-go/v2/testserver` per test and `schema.Migrate` applies the
embedded `schema/*.sql` migrations. `newTestDB(t)` (setup_test.go) returns a
`*tu.Context`; use `db.Ctx()` for both seeding (via `pgctx`) and the request
context. Google's token endpoint is stubbed through the `googleTokenURL` package
var. Tests download the CockroachDB binary on first run.

### Required environment variables

Expand All @@ -19,6 +25,8 @@ No test files exist in this codebase.
| `OAUTH2_CLIENT_ID` | Google OAuth app client ID |
| `OAUTH2_CLIENT_SECRET` | Google OAuth app client secret |
| `PORT` | Listen port (default: `8080`) |
| `BASE_URL` | Public base URL of this service (default: `https://auth.deploys.app`) |
| `INTROSPECTION_TOKEN` | Shared secret guarding `POST /introspect`; unset disables the endpoint |

## Architecture

Expand Down
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Required environment variables:
| `OAUTH2_CLIENT_ID` | Google OAuth app client ID |
| `OAUTH2_CLIENT_SECRET` | Google OAuth app client secret |
| `PORT` | Listen port (default: `8080`) |
| `BASE_URL` | Public base URL of this service (default: `https://auth.deploys.app`) |
| `INTROSPECTION_TOKEN` | Shared secret for the `/introspect` endpoint; if unset, introspection is disabled |

```shell
$ ./auth
Expand All @@ -31,11 +33,23 @@ the service starts.

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/` | Validate the OAuth2 client and redirect to Google |
| `GET` | `/.well-known/oauth-authorization-server` | OAuth 2.0 Authorization Server Metadata (RFC 8414) |
| `GET` | `/` | Validate the OAuth2 client and redirect to Google (authorize endpoint; supports PKCE) |
| `GET` | `/callback` | Receive Google's code and issue an internal auth code |
| `POST` | `/token` | Exchange client credentials + code for a user token |
| `POST` | `/token` | Exchange code (+ secret or PKCE verifier) for a user token |
| `POST` | `/register` | Dynamic Client Registration for public clients (RFC 7591) |
| `POST` | `/introspect` | Token introspection for resource servers (RFC 7662) |
| `POST` | `/revoke` | Revoke a user token |

### MCP / public clients

The service is an OAuth 2.1 authorization server. CLI / MCP clients register
dynamically at `/register` (public clients, no secret), use **PKCE (S256)** at
the authorize and token endpoints, and may use loopback redirect URIs
(`http://127.0.0.1:<port>`). Confidential web clients keep using
`client_secret` as before. A resource server validates issued bearer tokens via
`/introspect` (authenticated with `INTROSPECTION_TOKEN`).

## Deployment

The provided [Dockerfile](./Dockerfile) builds a `gcr.io/distroless/static`
Expand Down
150 changes: 150 additions & 0 deletions callback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package main

import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
)

// A single mock Google token endpoint is started lazily and shared by all
// callback tests. It keys the returned id_token email by the authorization
// code in the request, so parallel tests do not race over googleTokenURL.
var (
googleMockOnce sync.Once
googleMockMap sync.Map // code -> email
)

func registerGoogleCode(t *testing.T, code, email string) {
t.Helper()
googleMockOnce.Do(func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
email := "default@example.com"
if v, ok := googleMockMap.Load(r.FormValue("code")); ok {
email = v.(string)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"id_token":%q}`, fakeIDToken(email))
}))
googleTokenURL = srv.URL
})
googleMockMap.Store(code, email)
t.Cleanup(func() { googleMockMap.Delete(code) })
}

// --- OLD FLOW (regression): Google callback issues an internal code ---

func TestCallbackHandler_Confidential_Success(t *testing.T) {
t.Parallel()
registerGoogleCode(t, t.Name(), "user@example.com")

tdb := newTestDB(t)
ctx := tdb.Ctx()
seedConfidentialClient(t, ctx, "web", "topsecret", "https://app.example.com/*")
seedSession(t, ctx, "sess123", "web", "gstate", "cbstate", "https://app.example.com/cb", "", "", "")

q := url.Values{"state": {"gstate"}, "code": {t.Name()}}
req := getReqPath(t, "/callback", q)
req.AddCookie(&http.Cookie{Name: "s", Value: "sess123"})
rec := httptest.NewRecorder()
CallbackHandler{OAuth2ClientID: "g", OAuth2ClientSecret: "gs", BaseURL: "https://auth.test"}.
ServeHTTP(rec, req.WithContext(ctx))

if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302; body=%s", rec.Code, rec.Body.String())
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "https://app.example.com/cb?") {
t.Fatalf("Location = %q, want redirect to client callback", loc)
}
u, _ := url.Parse(loc)
if u.Query().Get("state") != "cbstate" {
t.Errorf("callback state = %q, want cbstate", u.Query().Get("state"))
}
returnedCode := u.Query().Get("code")
if returnedCode == "" {
t.Fatal("callback code is empty")
}
// The session is single-use and an internal code was minted for the email.
if n := countRows(t, ctx, "oauth2_sessions"); n != 0 {
t.Errorf("oauth2_sessions = %d, want 0 (consumed)", n)
}
email, challenge, method := codeEmailPKCE(t, ctx, returnedCode)
if email != "user@example.com" {
t.Errorf("code email = %q, want user@example.com", email)
}
if challenge != "" || method != "" {
t.Errorf("confidential code carried PKCE: (%q,%q)", challenge, method)
}
}

func TestCallbackHandler_MissingSessionCookie(t *testing.T) {
t.Parallel()
q := url.Values{"state": {"gstate"}, "code": {"google-code"}}
req := getReqPath(t, "/callback", q) // no cookie
rec := httptest.NewRecorder()
CallbackHandler{BaseURL: "https://auth.test"}.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}

func TestCallbackHandler_StateMismatch(t *testing.T) {
t.Parallel()
tdb := newTestDB(t)
ctx := tdb.Ctx()
seedSession(t, ctx, "sess123", "web", "expected-state", "cbstate", "https://app.example.com/cb", "", "", "")

q := url.Values{"state": {"attacker-state"}, "code": {"google-code"}}
req := getReqPath(t, "/callback", q)
req.AddCookie(&http.Cookie{Name: "s", Value: "sess123"})
rec := httptest.NewRecorder()
CallbackHandler{BaseURL: "https://auth.test"}.ServeHTTP(rec, req.WithContext(ctx))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (state mismatch)", rec.Code)
}
// Session is consumed on read even on mismatch; no code should be minted.
if n := countRows(t, ctx, "oauth2_codes"); n != 0 {
t.Errorf("oauth2_codes = %d, want 0", n)
}
}

// --- NEW FLOW: PKCE challenge from the session is carried onto the code ---

func TestCallbackHandler_Public_CarriesPKCE(t *testing.T) {
t.Parallel()
registerGoogleCode(t, t.Name(), "user@example.com")
_, challenge := pkcePair()

tdb := newTestDB(t)
ctx := tdb.Ctx()
seedPublicClient(t, ctx, "cli", "http://127.0.0.1:55001/callback")
seedSession(t, ctx, "sess123", "cli", "gstate", "cbstate", "http://127.0.0.1:55001/callback", challenge, "S256", "https://api.deploys.app")

q := url.Values{"state": {"gstate"}, "code": {t.Name()}}
req := getReqPath(t, "/callback", q)
req.AddCookie(&http.Cookie{Name: "s", Value: "sess123"})
rec := httptest.NewRecorder()
CallbackHandler{BaseURL: "https://auth.test"}.ServeHTTP(rec, req.WithContext(ctx))

if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302; body=%s", rec.Code, rec.Body.String())
}
u, _ := url.Parse(rec.Header().Get("Location"))
email, gotChallenge, gotMethod := codeEmailPKCE(t, ctx, u.Query().Get("code"))
if email != "user@example.com" {
t.Errorf("code email = %q", email)
}
if gotChallenge != challenge || gotMethod != "S256" {
t.Errorf("code PKCE = (%q,%q), want (%q,S256)", gotChallenge, gotMethod, challenge)
}
}

func getReqPath(t *testing.T, path string, q url.Values) *http.Request {
t.Helper()
return httptest.NewRequest(http.MethodGet, path+"?"+q.Encode(), nil)
}
33 changes: 33 additions & 0 deletions cleanup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package main

import (
"context"
"database/sql"
"log/slog"
"time"
)

// startCleanupWorker periodically removes expired oauth2 sessions and codes.
// Both have a 1-hour TTL but are only deleted on use, so abandoned rows would
// otherwise accumulate. Registered clients are never reaped — MCP clients reuse
// their client_id across logins.
func startCleanupWorker(db *sql.DB) {
go func() {
for {
cleanupExpired(db)
time.Sleep(15 * time.Minute)
}
}()
}

func cleanupExpired(db *sql.DB) {
ctx := context.Background()
for _, q := range []string{
`delete from oauth2_sessions where created_at < now() - interval '1 hour'`,
`delete from oauth2_codes where created_at < now() - interval '1 hour'`,
} {
if _, err := db.ExecContext(ctx, q); err != nil {
slog.ErrorContext(ctx, "cleanup: delete expired rows", "error", err)
}
}
}
7 changes: 7 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,10 @@ require (
github.com/acoshift/pgsql v0.16.0
github.com/lib/pq v1.12.3
)

require (
github.com/cockroachdb/cockroach-go/v2 v2.4.3
github.com/gofrs/flock v0.12.1 // indirect
golang.org/x/sys v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
19 changes: 17 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,28 @@ github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20O
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/acoshift/pgsql v0.16.0 h1:ak+fwy8Xnx0uZBhSmvFhGGk3a7EQNu8IiRLpnP99IT4=
github.com/acoshift/pgsql v0.16.0/go.mod h1:HtdMa77CYeRb9pD6+cT/ZPjpudiUVQ2OIKv6QXjgEZw=
github.com/cockroachdb/cockroach-go/v2 v2.4.3 h1:LJO3K3jC5WXvMePRQSJE1NsIGoFGcEx1LW83W6RAlhw=
github.com/cockroachdb/cockroach-go/v2 v2.4.3/go.mod h1:9U179XbCx4qFWtNhc7BiWLPfuyMVQ7qdAhfrwLz1vH0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Loading