diff --git a/.github/workflows/coweft-identity.yml b/.github/workflows/coweft-identity.yml
new file mode 100644
index 000000000..50fc22bbb
--- /dev/null
+++ b/.github/workflows/coweft-identity.yml
@@ -0,0 +1,22 @@
+name: CoWeft identity boundary
+on:
+ push:
+ branches: ['feat/coweft-oidc']
+ pull_request:
+ paths: ['apps/api-go/oidcprovider/**','apps/api-go/service/oidc_provider.go','apps/api-go/router/oidc_provider.go','apps/api-go/router/oauth_server.go','.github/workflows/coweft-identity.yml']
+permissions:
+ contents: read
+jobs:
+ provider:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: apps/api-go
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: apps/api-go/go.mod
+ cache-dependency-path: apps/api-go/go.sum
+ - run: go test -race ./oidcprovider
+ - run: go test ./service ./router -run 'OIDC|OAuth' -count=1
diff --git a/.gitmodules b/.gitmodules
index b69434a4f..7375c3c55 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -10,3 +10,6 @@
[submodule "packages/codewhale-lmm-provider"]
path = packages/codewhale-lmm-provider
url = https://github.com/TokenNotIncluded/codewhale-lmm-provider.git
+[submodule "apps/coweft"]
+ path = apps/coweft
+ url = https://github.com/TokenNotIncluded/coweft.git
diff --git a/apps/api-go/oidcprovider/attestation.go b/apps/api-go/oidcprovider/attestation.go
new file mode 100644
index 000000000..d7891a670
--- /dev/null
+++ b/apps/api-go/oidcprovider/attestation.go
@@ -0,0 +1,96 @@
+package oidcprovider
+
+import (
+ "context"
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "net/http"
+ "time"
+)
+
+// AttestationHandler requires BOTH the registered resource's Basic credential
+// and an active user grant in the JSON body. A user token alone cannot fabricate
+// an origin-node publication. The result is public evidence, NOT a bearer token.
+func (p *Provider) AttestationHandler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ if r.Method != "POST" {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ if !p.transport(r) {
+ fail(w, 400, "https_required")
+ return
+ }
+ if !p.allowed(r) {
+ fail(w, 429, "rate_limited")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ r = r.WithContext(ctx)
+ id, secret, ok := r.BasicAuth()
+ resource, found := p.resources[id]
+ if len(r.Header.Values("Authorization")) != 1 || !ok || !found || !same(secret, resource.Secret) {
+ fail(w, 401, "invalid_resource_client")
+ return
+ }
+ if r.Header.Get("Content-Type") != "application/json" {
+ fail(w, 400, "invalid_request")
+ return
+ }
+ var request struct {
+ Token string `json:"token"`
+ Digest string `json:"digest"`
+ Purpose string `json:"purpose"`
+ }
+ decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2048))
+ decoder.DisallowUnknownFields()
+ if decoder.Decode(&request) != nil || decoder.Decode(new(any)) != io.EOF || request.Purpose != "public-thread-v1" {
+ fail(w, 400, "invalid_request")
+ return
+ }
+ g, _, err := p.access(ctx, request.Token)
+ if err != nil {
+ fail(w, 401, "invalid_token")
+ return
+ }
+ if g.Request.Resource != resource.URI || !contains(g.Request.Scopes, "coweft:write") {
+ fail(w, 403, "insufficient_scope")
+ return
+ }
+ digestBytes, err := base64.RawURLEncoding.DecodeString(request.Digest)
+ if err != nil || len(digestBytes) != 32 || base64.RawURLEncoding.EncodeToString(digestBytes) != request.Digest {
+ fail(w, 400, "invalid_digest")
+ return
+ }
+ claims := map[string]any{
+ "iss": p.config.Issuer, "aud": "urn:coweft:public-thread-v1", "sub": g.Identity.Subject,
+ "resource": g.Request.Resource, "client_id": g.Request.ClientID, "controller": g.Controller,
+ "digest": request.Digest, "purpose": request.Purpose, "iat": time.Now().Unix(),
+ }
+ if contains(g.Request.Scopes, "profile") {
+ claims["name"] = g.Identity.Name
+ }
+ header, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "coweft-event+jwt", "kid": p.kid})
+ body, err := json.Marshal(claims)
+ if err != nil {
+ fail(w, 503, "server_error")
+ return
+ }
+ unsigned := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(body)
+ sum := sha256.Sum256([]byte(unsigned))
+ signature, err := rsa.SignPKCS1v15(rand.Reader, p.config.Key, crypto.SHA256, sum[:])
+ if err != nil {
+ fail(w, 503, "server_error")
+ return
+ }
+ jsonResponse(w, 200, map[string]string{"receipt": unsigned + "." + base64.RawURLEncoding.EncodeToString(signature), "purpose": "public-thread-v1"})
+ })
+}
diff --git a/apps/api-go/oidcprovider/attestation_test.go b/apps/api-go/oidcprovider/attestation_test.go
new file mode 100644
index 000000000..7d8f8c807
--- /dev/null
+++ b/apps/api-go/oidcprovider/attestation_test.go
@@ -0,0 +1,73 @@
+package oidcprovider
+
+import (
+ "context"
+ "crypto"
+ "crypto/rsa"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+func receiptRequest(token, contentDigest string) *http.Request {
+ body, _ := json.Marshal(map[string]string{"token": token, "digest": contentDigest, "purpose": "public-thread-v1"})
+ request := httptest.NewRequest("POST", "https://api.lmm.best/api/oidc/attest", strings.NewReader(string(body)))
+ request.Header.Set("Content-Type", "application/json")
+ request.SetBasicAuth("coweft", strings.Repeat("s", 32))
+ return request
+}
+func TestPublicReceiptRequiresNodeAndUserAndCannotBecomeACredential(t *testing.T) {
+ p, _ := setup(t)
+ tokens := tokenFor(t, p)
+ access := tokens["access_token"].(string)
+ request := receiptRequest(access, digest("public content"))
+ response := httptest.NewRecorder()
+ p.AttestationHandler().ServeHTTP(response, request)
+ if response.Code != 200 {
+ t.Fatal(response.Code, response.Body.String())
+ }
+ var result map[string]string
+ json.Unmarshal(response.Body.Bytes(), &result)
+ parts := strings.Split(result["receipt"], ".")
+ if len(parts) != 3 {
+ t.Fatal("invalid receipt")
+ }
+ headerBytes, _ := base64.RawURLEncoding.DecodeString(parts[0])
+ var header map[string]string
+ json.Unmarshal(headerBytes, &header)
+ if header["typ"] != "coweft-event+jwt" {
+ t.Fatal("wrong receipt type")
+ }
+ signature, _ := base64.RawURLEncoding.DecodeString(parts[2])
+ sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
+ if rsa.VerifyPKCS1v15(&p.config.Key.PublicKey, crypto.SHA256, sum[:], signature) != nil {
+ t.Fatal("signature invalid")
+ }
+ claimBytes, _ := base64.RawURLEncoding.DecodeString(parts[1])
+ var claims map[string]any
+ json.Unmarshal(claimBytes, &claims)
+ if claims["digest"] != digest("public content") || claims["sub"] != "lmm:7" || claims["aud"] != "urn:coweft:public-thread-v1" {
+ t.Fatal(claims)
+ }
+ if _, _, err := p.access(context.Background(), result["receipt"]); err == nil {
+ t.Fatal("public receipt accepted as bearer token")
+ }
+ request = receiptRequest(access, digest("forged origin"))
+ request.Header.Set("Authorization", "Bearer "+access)
+ response = httptest.NewRecorder()
+ p.AttestationHandler().ServeHTTP(response, request)
+ if response.Code != 401 {
+ t.Fatal("user token alone could impersonate resource approval")
+ }
+ post(p, "/api/oidc/revoke", url.Values{"token": {access}, "client_id": {"coweft-web"}}, false)
+ response = httptest.NewRecorder()
+ p.AttestationHandler().ServeHTTP(response, receiptRequest(access, digest("public content")))
+ if response.Code != 401 {
+ t.Fatal("revoked grant could issue receipt")
+ }
+}
diff --git a/apps/api-go/oidcprovider/browser.go b/apps/api-go/oidcprovider/browser.go
new file mode 100644
index 000000000..c677df496
--- /dev/null
+++ b/apps/api-go/oidcprovider/browser.go
@@ -0,0 +1,265 @@
+package oidcprovider
+
+import (
+ "html/template"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+type authorization struct {
+ ClientID string `json:"client_id"`
+ Redirect string `json:"redirect_uri"`
+ Resource string `json:"resource"`
+ Scopes []string `json:"scopes"`
+ State string `json:"state"`
+ Nonce string `json:"nonce"`
+ Challenge string `json:"challenge"`
+}
+type flow struct {
+ Request authorization
+ Identity Identity
+ Binding string
+ CSRF string
+}
+type grant struct {
+ ID string `json:"id"`
+ Identity Identity `json:"identity"`
+ Request authorization `json:"request"`
+ Controller string `json:"controller"`
+ CreatedAt int64 `json:"created_at"`
+ ExpiresAt int64 `json:"expires_at"`
+}
+
+func (p *Provider) parseAuthorization(q url.Values) (authorization, error) {
+ var a authorization
+ for k, v := range q {
+ if len(v) != 1 || !contains([]string{"client_id", "redirect_uri", "response_type", "scope", "resource", "state", "nonce", "code_challenge", "code_challenge_method"}, k) {
+ return a, ErrDenied
+ }
+ }
+ c, ok := p.clients[q.Get("client_id")]
+ if !ok || !redirectMatches(c, q.Get("redirect_uri")) || q.Get("response_type") != "code" || q.Get("code_challenge_method") != "S256" || !contains(c.Resources, q.Get("resource")) {
+ return a, ErrDenied
+ }
+ if len(q.Get("state")) < 16 || len(q.Get("state")) > 512 || len(q.Get("code_challenge")) != 43 {
+ return a, ErrDenied
+ }
+ challenge := q.Get("code_challenge")
+ for _, r := range challenge {
+ if !strings.ContainsRune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_", r) {
+ return a, ErrDenied
+ }
+ }
+ scopes := strings.Fields(q.Get("scope"))
+ if len(scopes) == 0 {
+ return a, ErrDenied
+ }
+ seen := map[string]bool{}
+ for _, scope := range scopes {
+ if seen[scope] || !contains(c.Scopes, scope) {
+ return a, ErrDenied
+ }
+ seen[scope] = true
+ }
+ if contains(scopes, "openid") && (len(q.Get("nonce")) < 16 || len(q.Get("nonce")) > 512) {
+ return a, ErrDenied
+ }
+ for _, s := range scopes {
+ if strings.HasPrefix(s, "coweft:") && !contains(scopes, "coweft:read") {
+ return a, ErrDenied
+ }
+ }
+ return authorization{c.ID, q.Get("redirect_uri"), q.Get("resource"), scopes, q.Get("state"), q.Get("nonce"), challenge}, nil
+}
+func (p *Provider) authorize(w http.ResponseWriter, r *http.Request) {
+ q, e := url.ParseQuery(r.URL.RawQuery)
+ if e != nil {
+ fail(w, 400, "invalid_request")
+ return
+ }
+ a, e := p.parseAuthorization(q)
+ if e != nil {
+ fail(w, 400, "invalid_request")
+ return
+ }
+ identity, e := p.config.BrowserIdentity(r.Context(), r)
+ if e != nil {
+ // The existing account login owns authentication. This endpoint never
+ // accepts usernames, account IDs, passwords or dashboard tokens from forms.
+ http.Redirect(w, r, p.origin+"/login?redirect="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
+ return
+ }
+ if identity.Subject == "" || p.config.ValidateIdentity(r.Context(), identity) != nil {
+ fail(w, 401, "login_required")
+ return
+ }
+ tx, e := random()
+ if e != nil {
+ fail(w, 503, "server_error")
+ return
+ }
+ binding, e := random()
+ if e != nil {
+ fail(w, 503, "server_error")
+ return
+ }
+ csrf, e := random()
+ if e != nil {
+ fail(w, 503, "server_error")
+ return
+ }
+ f := flow{a, identity, digest(binding), csrf}
+ if e = p.put(r.Context(), "flow:"+digest(tx), f, time.Now().Add(5*time.Minute).Unix(), identity.Subject); e != nil {
+ fail(w, 503, "server_error")
+ return
+ }
+ setCookie(w, "__Host-lmm-oidc-flow", binding, 300)
+ p.page(w, pageData{Title: "授权给 " + p.clients[a.ClientID].Name, Name: identity.Name, Client: p.clients[a.ClientID].Name, Controller: p.clients[a.ClientID].Controller, Resource: a.Resource, Scopes: a.Scopes, Transaction: tx, CSRF: csrf, Action: "/api/user/auth/oidc/consent"})
+}
+func (p *Provider) consent(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Origin") != p.origin {
+ fail(w, 403, "csrf_rejected")
+ return
+ }
+ form, e := parseForm(w, r)
+ if e != nil {
+ fail(w, 400, "invalid_request")
+ return
+ }
+ binding, ok := browserCookie(r, "__Host-lmm-oidc-flow")
+ if !ok {
+ fail(w, 403, "csrf_rejected")
+ return
+ }
+ var f flow
+ if e = p.get(r.Context(), "flow:"+digest(form.Get("transaction")), &f, true); e != nil {
+ fail(w, 400, "expired_authorization")
+ return
+ }
+ setCookie(w, "__Host-lmm-oidc-flow", "", -1)
+ if !same(digest(binding), f.Binding) || !same(form.Get("csrf"), f.CSRF) {
+ fail(w, 403, "csrf_rejected")
+ return
+ }
+ identity, e := p.config.BrowserIdentity(r.Context(), r)
+ if e != nil || identity.Subject != f.Identity.Subject || identity.SessionID != f.Identity.SessionID || identity.SessionVersion != f.Identity.SessionVersion || identity.AuthVersion != f.Identity.AuthVersion || p.config.ValidateIdentity(r.Context(), identity) != nil {
+ fail(w, 401, "identity_changed")
+ return
+ }
+ if form.Get("decision") == "deny" {
+ p.redirect(w, r, f.Request, "", "access_denied")
+ return
+ }
+ if form.Get("decision") != "allow" {
+ fail(w, 400, "invalid_request")
+ return
+ }
+ code, e := random()
+ if e != nil {
+ fail(w, 503, "server_error")
+ return
+ }
+ g := grant{Identity: identity, Request: f.Request, Controller: p.clients[f.Request.ClientID].Controller, CreatedAt: time.Now().Unix(), ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Unix()}
+ if e = p.put(r.Context(), "code:"+digest(code), g, time.Now().Add(120*time.Second).Unix(), identity.Subject); e != nil {
+ fail(w, 503, "server_error")
+ return
+ }
+ p.redirect(w, r, f.Request, code, "")
+}
+func (p *Provider) redirect(w http.ResponseWriter, r *http.Request, a authorization, code, problem string) {
+ u, _ := url.Parse(a.Redirect)
+ q := u.Query()
+ q.Set("state", a.State)
+ q.Set("iss", p.config.Issuer)
+ if code != "" {
+ q.Set("code", code)
+ }
+ if problem != "" {
+ q.Set("error", problem)
+ }
+ u.RawQuery = q.Encode()
+ http.Redirect(w, r, u.String(), http.StatusSeeOther)
+}
+
+type pageData struct {
+ Title, Name, Client, Controller, Resource, Transaction, CSRF, Action string
+ Scopes []string
+ Grants []grant
+ Manage bool
+}
+
+var page = template.Must(template.New("consent").Parse(`
{{.Title}} · LMMLMM · 子项目身份与授权{{.Title}}
当前账号:{{.Name}}
{{if .Manage}}撤销后,网页与 AI 的下一次资源访问会重新检查授权。已公开的内容不会被删除。
{{range .Grants}}{{.Request.ClientID}} · {{.Controller}}
{{.Request.Resource}}
{{range .Request.Scopes}}{{.}} {{end}}
{{else}}没有有效的子项目授权。
{{end}}{{else}}{{.Client}} 将作为 {{.Controller}} 操作者访问:
{{.Resource}}
{{range .Scopes}}{{.}} {{end}}
登录不会自动授予模型消费权限。人和 AI 使用不同凭证,但归属同一个账号。只同意你准备开放的权限。
{{end}}`))
+
+func (p *Provider) page(w http.ResponseWriter, data pageData) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
+ _ = page.Execute(w, data)
+}
+func (p *Provider) grants(w http.ResponseWriter, r *http.Request) {
+ identity, e := p.config.BrowserIdentity(r.Context(), r)
+ if e != nil || p.config.ValidateIdentity(r.Context(), identity) != nil {
+ http.Redirect(w, r, "/login?redirect=%2Fapi%2Fuser%2Fauth%2Foidc%2Fgrants", 303)
+ return
+ }
+ records, e := p.config.Store.Families(r.Context(), identity.Subject)
+ if e != nil {
+ fail(w, 503, "server_error")
+ return
+ }
+ var grants []grant
+ for _, record := range records {
+ var g grant
+ if jsonUnmarshal(record, &g) == nil && g.Identity.Subject == identity.Subject && g.ExpiresAt > time.Now().Unix() {
+ grants = append(grants, g)
+ }
+ }
+ csrf, e := random()
+ if e != nil {
+ fail(w, 503, "server_error")
+ return
+ }
+ if e = p.put(r.Context(), "manage:"+digest(csrf), identity, time.Now().Add(5*time.Minute).Unix(), identity.Subject); e != nil {
+ fail(w, 503, "server_error")
+ return
+ }
+ setCookie(w, "__Host-lmm-oidc-manage", csrf, 300)
+ p.page(w, pageData{Title: "管理子项目授权", Name: identity.Name, Manage: true, CSRF: csrf, Grants: grants})
+}
+func (p *Provider) manage(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Origin") != p.origin {
+ fail(w, 403, "csrf_rejected")
+ return
+ }
+ form, e := parseForm(w, r)
+ if e != nil {
+ fail(w, 400, "invalid_request")
+ return
+ }
+ cookie, ok := browserCookie(r, "__Host-lmm-oidc-manage")
+ if !ok || !same(cookie, form.Get("csrf")) {
+ fail(w, 403, "csrf_rejected")
+ return
+ }
+ var original Identity
+ if e = p.get(r.Context(), "manage:"+digest(cookie), &original, true); e != nil {
+ fail(w, 403, "csrf_rejected")
+ return
+ }
+ current, e := p.config.BrowserIdentity(r.Context(), r)
+ if e != nil || current.Subject != original.Subject || current.SessionID != original.SessionID || p.config.ValidateIdentity(r.Context(), current) != nil {
+ fail(w, 401, "login_required")
+ return
+ }
+ var g grant
+ key := "family:" + form.Get("grant_id")
+ if e = p.get(r.Context(), key, &g, false); e == nil && g.Identity.Subject == current.Subject {
+ if e = p.config.Store.Delete(r.Context(), key); e != nil {
+ fail(w, 503, "server_error")
+ return
+ }
+ }
+ setCookie(w, "__Host-lmm-oidc-manage", "", -1)
+ http.Redirect(w, r, "/api/user/auth/oidc/grants", 303)
+}
diff --git a/apps/api-go/oidcprovider/browser_integration_test.go b/apps/api-go/oidcprovider/browser_integration_test.go
new file mode 100644
index 000000000..f5a217c80
--- /dev/null
+++ b/apps/api-go/oidcprovider/browser_integration_test.go
@@ -0,0 +1,114 @@
+package oidcprovider
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+func TestBrowserConsentPKCEAndSingleUse(t *testing.T) {
+ p, _ := setup(t)
+ authorize := httptest.NewRequest("GET", "https://api.lmm.best/api/user/auth/oidc/authorize?"+query().Encode(), nil)
+ page := httptest.NewRecorder()
+ p.BrowserEntryHandler().ServeHTTP(page, authorize)
+ if page.Code != 200 {
+ t.Fatal(page.Code, page.Body.String())
+ }
+ fields := map[string]string{}
+ for _, match := range regexp.MustCompile(`name="(transaction|csrf)" value="([^"]+)"`).FindAllStringSubmatch(page.Body.String(), -1) {
+ fields[match[1]] = match[2]
+ }
+ if fields["transaction"] == "" || fields["csrf"] == "" {
+ t.Fatal("missing consent fields")
+ }
+ form := url.Values{"transaction": {fields["transaction"]}, "csrf": {fields["csrf"]}, "decision": {"allow"}}
+ request := httptest.NewRequest("POST", "https://api.lmm.best/api/user/auth/oidc/consent", strings.NewReader(form.Encode()))
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ request.Header.Set("Origin", "https://api.lmm.best")
+ for _, cookie := range page.Result().Cookies() {
+ request.AddCookie(cookie)
+ }
+ response := httptest.NewRecorder()
+ p.Handler().ServeHTTP(response, request)
+ if response.Code != 303 {
+ t.Fatal(response.Code, response.Body.String())
+ }
+ target, err := url.Parse(response.Header().Get("Location"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ code := target.Query().Get("code")
+ if code == "" || target.Query().Get("state") != query().Get("state") || target.Query().Get("iss") != p.config.Issuer {
+ t.Fatal("invalid authorization response", target)
+ }
+ values := url.Values{"grant_type": {"authorization_code"}, "client_id": {"coweft-web"}, "redirect_uri": {"https://forum.example/auth/callback"}, "resource": {"https://forum.example/mcp"}, "code": {code}, "code_verifier": {strings.Repeat("v", 43)}}
+ response = post(p, "/api/oidc/token", values, false)
+ if response.Code != 200 {
+ t.Fatal(response.Code, response.Body.String())
+ }
+ var tokens map[string]any
+ json.Unmarshal(response.Body.Bytes(), &tokens)
+ parts := strings.Split(tokens["id_token"].(string), ".")
+ raw, _ := base64.RawURLEncoding.DecodeString(parts[1])
+ var claims map[string]any
+ json.Unmarshal(raw, &claims)
+ if claims["nonce"] != query().Get("nonce") || claims["aud"] != "coweft-web" || claims["sub"] != "lmm:7" {
+ t.Fatal(claims)
+ }
+ if post(p, "/api/oidc/token", values, false).Code != 400 {
+ t.Fatal("authorization code replay accepted")
+ }
+}
+func TestLoginBridgePreservesValidatedFlowWithoutFrontendRedirectAssumption(t *testing.T) {
+ p, _ := setup(t)
+ p.config.BrowserIdentity = func(context.Context, *http.Request) (Identity, error) { return Identity{}, ErrDenied }
+ request := httptest.NewRequest("GET", "https://api.lmm.best/api/user/auth/oidc/authorize?"+query().Encode(), nil)
+ response := httptest.NewRecorder()
+ p.BrowserEntryHandler().ServeHTTP(response, request)
+ if response.Code != 200 || !strings.Contains(response.Body.String(), "已登录,继续授权") || !strings.Contains(response.Body.String(), `href="/login"`) {
+ t.Fatal(response.Body.String())
+ }
+ bad := query()
+ bad.Set("redirect_uri", "https://attacker.example")
+ response = httptest.NewRecorder()
+ p.BrowserEntryHandler().ServeHTTP(response, httptest.NewRequest("GET", "https://api.lmm.best/api/user/auth/oidc/authorize?"+bad.Encode(), nil))
+ if response.Code != 400 {
+ t.Fatal("unregistered redirect rendered")
+ }
+}
+
+// Only a PUBLIC key/receipt leaves this test; no private key or bearer token.
+func TestExportInteroperabilityFixture(t *testing.T) {
+ path := os.Getenv("COWEFT_INTEROP_FIXTURE")
+ if path == "" {
+ t.Skip("fixture export not requested")
+ }
+ p, _ := setup(t)
+ tokens := tokenFor(t, p)
+ snapshot := `{"version":1,"source":"https://forum.example","id":"a59cdd36-9229-4d17-a2e5-41c810e122b1","title":"Cross-language publication","body":"Public evidence from the Go provider.","kind":"discussion","revision":1}`
+ response := httptest.NewRecorder()
+ p.AttestationHandler().ServeHTTP(response, receiptRequest(tokens["access_token"].(string), digest(snapshot)))
+ if response.Code != 200 {
+ t.Fatal(response.Code, response.Body.String())
+ }
+ var result map[string]string
+ json.Unmarshal(response.Body.Bytes(), &result)
+ public := httptest.NewRecorder()
+ p.jwks(public)
+ var jwks any
+ json.Unmarshal(public.Body.Bytes(), &jwks)
+ fixture, err := json.Marshal(map[string]any{"jwks": jwks, "envelope": map[string]string{"payload": base64.RawURLEncoding.EncodeToString([]byte(snapshot)), "receipt": result["receipt"]}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = os.WriteFile(path, fixture, 0600); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/apps/api-go/oidcprovider/login_bridge.go b/apps/api-go/oidcprovider/login_bridge.go
new file mode 100644
index 000000000..6fae3bf54
--- /dev/null
+++ b/apps/api-go/oidcprovider/login_bridge.go
@@ -0,0 +1,61 @@
+package oidcprovider
+
+import (
+ "context"
+ "html/template"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+// BrowserEntryHandler keeps the authorization request in the original tab.
+// A SameSite=Strict login cookie may be absent on the first cross-site GET even
+// when the user is already signed in. A same-site Continue link makes the next
+// request eligible without widening the existing refresh cookie or relying on
+// unverified frontend redirect parameters. Login itself remains entirely LMM's.
+func (p *Provider) BrowserEntryHandler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("Referrer-Policy", "strict-origin")
+ if r.Method != "GET" {
+ p.Handler().ServeHTTP(w, r)
+ return
+ }
+ if !p.transport(r) {
+ fail(w, 400, "https_required")
+ return
+ }
+ if !p.allowed(r) {
+ fail(w, 429, "rate_limited")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ r = r.WithContext(ctx)
+ if r.URL.Path == "/api/user/auth/oidc/authorize" {
+ query, err := url.ParseQuery(r.URL.RawQuery)
+ if err != nil {
+ fail(w, 400, "invalid_request")
+ return
+ }
+ if _, err = p.parseAuthorization(query); err != nil {
+ fail(w, 400, "invalid_request")
+ return
+ }
+ } else if r.URL.Path != "/api/user/auth/oidc/grants" {
+ http.NotFound(w, r)
+ return
+ }
+ identity, err := p.config.BrowserIdentity(ctx, r)
+ if err == nil && p.config.ValidateIdentity(ctx, identity) == nil {
+ p.Handler().ServeHTTP(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'")
+ _ = loginBridge.Execute(w, struct{ Continue string }{r.URL.RequestURI()})
+ })
+}
+
+var loginBridge = template.Must(template.New("login-bridge").Parse(`继续 LMM 授权LMM · 统一身份使用你的 LMM 账号
已经登录,直接继续。尚未登录,在新标签页完成 LMM 登录后回到这里;原来的授权请求会保留。
已登录,继续授权打开 LMM 登录`))
diff --git a/apps/api-go/oidcprovider/provider.go b/apps/api-go/oidcprovider/provider.go
new file mode 100644
index 000000000..79e6e04fd
--- /dev/null
+++ b/apps/api-go/oidcprovider/provider.go
@@ -0,0 +1,365 @@
+// Package oidcprovider implements the LMM first-party subproject identity
+// boundary. It has no passwords, account creation or alternate login methods.
+// Browser identity and current-session validation are injected by api.lmm.best.
+package oidcprovider
+
+import (
+ "context"
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/subtle"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math/big"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+)
+
+var ErrMissing = errors.New("identity record missing")
+var ErrDenied = errors.New("identity unavailable or revoked")
+
+// Store.Take MUST consume a record atomically across every server instance.
+// Only token hashes, never raw bearer credentials, are used as storage keys.
+type Store interface {
+ Set(context.Context, string, []byte, int64, string) error
+ Get(context.Context, string) ([]byte, error)
+ Take(context.Context, string) ([]byte, error)
+ Delete(context.Context, string) error
+ Families(context.Context, string) ([][]byte, error)
+}
+type Identity struct {
+ Subject string `json:"subject"`
+ Name string `json:"name"`
+ SessionID string `json:"session_id"`
+ SessionVersion int64 `json:"session_version"`
+ AuthVersion int64 `json:"auth_version"`
+}
+type Client struct {
+ ID string `json:"client_id"`
+ Name string `json:"name"`
+ RedirectURIs []string `json:"redirect_uris"`
+ Resources []string `json:"resources"`
+ Scopes []string `json:"scopes"`
+ Controller string `json:"controller"`
+ Loopback bool `json:"loopback"`
+}
+type Resource struct {
+ ID string `json:"id"`
+ URI string `json:"uri"`
+ Secret string `json:"-"`
+ SecretEnv string `json:"secret_env"`
+}
+type Config struct {
+ Issuer string
+ Clients []Client
+ Resources []Resource
+ Key *rsa.PrivateKey
+ Store Store
+ BrowserIdentity func(context.Context, *http.Request) (Identity, error)
+ ValidateIdentity func(context.Context, Identity) error
+ TrustedProxies []*net.IPNet
+}
+type bucket struct {
+ start time.Time
+ count int
+}
+type Provider struct {
+ config Config
+ origin string
+ kid string
+ clients map[string]Client
+ resources map[string]Resource
+ mu sync.Mutex
+ rates map[string]bucket
+}
+
+var knownScopes = []string{"openid", "profile", "coweft:read", "coweft:write", "coweft:propose", "coweft:vote"}
+
+func contains(values []string, value string) bool {
+ for _, v := range values {
+ if v == value {
+ return true
+ }
+ }
+ return false
+}
+func digest(s string) string {
+ h := sha256.Sum256([]byte(s))
+ return base64.RawURLEncoding.EncodeToString(h[:])
+}
+func same(a, b string) bool {
+ x := sha256.Sum256([]byte(a))
+ y := sha256.Sum256([]byte(b))
+ return subtle.ConstantTimeCompare(x[:], y[:]) == 1
+}
+func random() (string, error) {
+ var b [32]byte
+ if _, e := rand.Read(b[:]); e != nil {
+ return "", e
+ }
+ return base64.RawURLEncoding.EncodeToString(b[:]), nil
+}
+func New(c Config) (*Provider, error) {
+ u, e := url.Parse(c.Issuer)
+ if e != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "/oidc" || u.RawQuery != "" || u.Fragment != "" {
+ return nil, fmt.Errorf("OIDC issuer must be a fixed HTTPS origin followed by /oidc")
+ }
+ if c.Key == nil || c.Key.N.BitLen() < 2048 || c.Store == nil || c.BrowserIdentity == nil || c.ValidateIdentity == nil {
+ return nil, fmt.Errorf("OIDC requires an RSA key, persistent storage and trusted identity callbacks")
+ }
+ if e := c.Key.Validate(); e != nil {
+ return nil, e
+ }
+ der, e := x509.MarshalPKIXPublicKey(&c.Key.PublicKey)
+ if e != nil {
+ return nil, e
+ }
+ kid := digest(string(der))[:22]
+ p := &Provider{config: c, origin: u.Scheme + "://" + u.Host, kid: kid, clients: map[string]Client{}, resources: map[string]Resource{}, rates: map[string]bucket{}}
+ for _, r := range c.Resources {
+ uri, e := url.Parse(r.URI)
+ if r.ID == "" || len(r.Secret) < 32 || e != nil || uri.Scheme != "https" || uri.Host == "" || uri.User != nil || uri.RawQuery != "" || uri.Fragment != "" {
+ return nil, fmt.Errorf("invalid OIDC resource registration")
+ }
+ if _, exists := p.resources[r.ID]; exists {
+ return nil, fmt.Errorf("duplicate resource")
+ }
+ p.resources[r.ID] = r
+ }
+ if len(p.resources) == 0 {
+ return nil, fmt.Errorf("at least one resource must be explicitly registered")
+ }
+ for _, client := range c.Clients {
+ if client.ID == "" || client.Name == "" || len(client.RedirectURIs) == 0 || len(client.Resources) == 0 || (client.Controller != "human" && client.Controller != "agent") {
+ return nil, fmt.Errorf("invalid client registration")
+ }
+ if _, ok := p.clients[client.ID]; ok {
+ return nil, fmt.Errorf("duplicate client")
+ }
+ for _, redirect := range client.RedirectURIs {
+ if !validRedirectTemplate(redirect, client.Loopback) {
+ return nil, fmt.Errorf("invalid redirect for %s", client.ID)
+ }
+ }
+ for _, s := range client.Scopes {
+ if !contains(knownScopes, s) {
+ return nil, fmt.Errorf("unknown scope %s", s)
+ }
+ }
+ for _, uri := range client.Resources {
+ found := false
+ for _, r := range c.Resources {
+ if r.URI == uri {
+ found = true
+ }
+ }
+ if !found {
+ return nil, fmt.Errorf("unregistered resource")
+ }
+ }
+ p.clients[client.ID] = client
+ }
+ return p, nil
+}
+func validRedirectTemplate(raw string, loopback bool) bool {
+ u, e := url.Parse(raw)
+ if e != nil || u.User != nil || u.Fragment != "" || u.RawQuery != "" || u.Host == "" || u.Path == "" {
+ return false
+ }
+ if loopback {
+ return u.Scheme == "http" && u.Hostname() == "127.0.0.1" && u.Port() == ""
+ }
+ return u.Scheme == "https"
+}
+func redirectMatches(c Client, raw string) bool {
+ for _, registered := range c.RedirectURIs {
+ if !c.Loopback && raw == registered {
+ return true
+ }
+ if c.Loopback {
+ u, e := url.Parse(raw)
+ t, _ := url.Parse(registered)
+ if e == nil && u.Scheme == "http" && u.Hostname() == "127.0.0.1" && u.Port() != "" && u.User == nil && u.RawQuery == "" && u.Fragment == "" && u.Path == t.Path {
+ return true
+ }
+ }
+ }
+ return false
+}
+func (p *Provider) put(ctx context.Context, key string, v any, expires int64, owner string) error {
+ b, e := json.Marshal(v)
+ if e != nil {
+ return e
+ }
+ return p.config.Store.Set(ctx, key, b, expires, owner)
+}
+func (p *Provider) get(ctx context.Context, key string, v any, take bool) error {
+ var b []byte
+ var e error
+ if take {
+ b, e = p.config.Store.Take(ctx, key)
+ } else {
+ b, e = p.config.Store.Get(ctx, key)
+ }
+ if e != nil {
+ return e
+ }
+ return json.Unmarshal(b, v)
+}
+func jsonResponse(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+func fail(w http.ResponseWriter, status int, code string) {
+ jsonResponse(w, status, map[string]string{"error": code})
+}
+func (p *Provider) transport(r *http.Request) bool {
+ if r.TLS != nil {
+ return true
+ }
+ host, _, e := net.SplitHostPort(r.RemoteAddr)
+ if e != nil {
+ return false
+ }
+ ip := net.ParseIP(host)
+ for _, network := range p.config.TrustedProxies {
+ if network.Contains(ip) && len(r.Header.Values("X-Forwarded-Proto")) == 1 && r.Header.Get("X-Forwarded-Proto") == "https" {
+ return true
+ }
+ }
+ return false
+}
+func (p *Provider) allowed(r *http.Request) bool {
+ host, _, _ := net.SplitHostPort(r.RemoteAddr)
+ key := host + ":" + r.URL.Path
+ now := time.Now()
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ b, exists := p.rates[key]
+ if !exists || now.Sub(b.start) >= time.Minute {
+ if len(p.rates) >= 4096 {
+ for k, v := range p.rates {
+ if now.Sub(v.start) >= time.Minute {
+ delete(p.rates, k)
+ }
+ }
+ }
+ if !exists && len(p.rates) >= 4096 {
+ return false
+ }
+ b = bucket{start: now}
+ }
+ b.count++
+ p.rates[key] = b
+ return b.count <= 120
+}
+func (p *Provider) Handler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Pragma", "no-cache")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("Referrer-Policy", "strict-origin")
+ w.Header().Set("X-Frame-Options", "DENY")
+ if !p.transport(r) {
+ fail(w, 400, "https_required")
+ return
+ }
+ if !p.allowed(r) {
+ w.Header().Set("Retry-After", "60")
+ fail(w, 429, "rate_limited")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ r = r.WithContext(ctx)
+ switch r.Method + " " + r.URL.Path {
+ case "GET /oidc/.well-known/openid-configuration", "GET /.well-known/oauth-authorization-server/oidc":
+ p.discovery(w)
+ case "GET /api/oidc/jwks":
+ p.jwks(w)
+ case "GET /api/user/auth/oidc/authorize":
+ p.authorize(w, r)
+ case "POST /api/user/auth/oidc/consent":
+ p.consent(w, r)
+ case "POST /api/oidc/token":
+ p.token(w, r)
+ case "GET /api/oidc/userinfo", "POST /api/oidc/userinfo":
+ p.userinfo(w, r)
+ case "POST /api/oidc/introspect":
+ p.introspect(w, r)
+ case "POST /api/oidc/revoke":
+ p.revoke(w, r)
+ case "GET /api/user/auth/oidc/grants":
+ p.grants(w, r)
+ case "POST /api/user/auth/oidc/grants":
+ p.manage(w, r)
+ default:
+ http.NotFound(w, r)
+ }
+ })
+}
+func (p *Provider) discovery(w http.ResponseWriter) {
+ jsonResponse(w, 200, map[string]any{
+ "issuer": p.config.Issuer, "authorization_endpoint": p.origin + "/api/user/auth/oidc/authorize", "token_endpoint": p.origin + "/api/oidc/token", "userinfo_endpoint": p.origin + "/api/oidc/userinfo", "jwks_uri": p.origin + "/api/oidc/jwks", "introspection_endpoint": p.origin + "/api/oidc/introspect", "revocation_endpoint": p.origin + "/api/oidc/revoke",
+ "response_types_supported": []string{"code"}, "grant_types_supported": []string{"authorization_code", "refresh_token"}, "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{"RS256"}, "token_endpoint_auth_methods_supported": []string{"none"}, "revocation_endpoint_auth_methods_supported": []string{"none"}, "introspection_endpoint_auth_methods_supported": []string{"client_secret_basic"}, "code_challenge_methods_supported": []string{"S256"}, "scopes_supported": knownScopes, "claims_supported": []string{"iss", "sub", "aud", "exp", "iat", "nonce", "name", "preferred_username", "at_hash"}, "authorization_response_iss_parameter_supported": true,
+ })
+}
+func (p *Provider) jwks(w http.ResponseWriter) {
+ jsonResponse(w, 200, map[string]any{"keys": []any{map[string]any{"kty": "RSA", "use": "sig", "alg": "RS256", "kid": p.kid, "n": base64.RawURLEncoding.EncodeToString(p.config.Key.N.Bytes()), "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(p.config.Key.E)).Bytes())}}})
+}
+func (p *Provider) sign(claims any) (string, error) {
+ h, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT", "kid": p.kid})
+ b, e := json.Marshal(claims)
+ if e != nil {
+ return "", e
+ }
+ raw := base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(b)
+ sum := sha256.Sum256([]byte(raw))
+ sig, e := rsa.SignPKCS1v15(rand.Reader, p.config.Key, crypto.SHA256, sum[:])
+ if e != nil {
+ return "", e
+ }
+ return raw + "." + base64.RawURLEncoding.EncodeToString(sig), nil
+}
+func parseForm(w http.ResponseWriter, r *http.Request) (url.Values, error) {
+ if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") {
+ return nil, ErrDenied
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
+ if e := r.ParseForm(); e != nil {
+ return nil, e
+ }
+ if r.URL.RawQuery != "" {
+ return nil, ErrDenied
+ }
+ for _, v := range r.PostForm {
+ if len(v) != 1 {
+ return nil, ErrDenied
+ }
+ }
+ return r.PostForm, nil
+}
+func browserCookie(r *http.Request, name string) (string, bool) {
+ value := ""
+ count := 0
+ for _, c := range r.Cookies() {
+ if c.Name == name {
+ count++
+ value = c.Value
+ }
+ }
+ return value, count == 1 && len(value) == 43
+}
+func setCookie(w http.ResponseWriter, name, value string, age int) {
+ http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: age})
+}
diff --git a/apps/api-go/oidcprovider/provider_test.go b/apps/api-go/oidcprovider/provider_test.go
new file mode 100644
index 000000000..0f6412bf6
--- /dev/null
+++ b/apps/api-go/oidcprovider/provider_test.go
@@ -0,0 +1,259 @@
+package oidcprovider
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/tls"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+type entry struct {
+ value []byte
+ expires int64
+ owner string
+}
+type memoryStore struct {
+ mu sync.Mutex
+ rows map[string]entry
+}
+
+func (s *memoryStore) Set(_ context.Context, k string, v []byte, exp int64, owner string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.rows[k] = entry{append([]byte{}, v...), exp, owner}
+ return nil
+}
+func (s *memoryStore) Get(_ context.Context, k string) ([]byte, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ v, ok := s.rows[k]
+ if !ok || v.expires <= time.Now().Unix() {
+ return nil, ErrMissing
+ }
+ return append([]byte{}, v.value...), nil
+}
+func (s *memoryStore) Take(_ context.Context, k string) ([]byte, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ v, ok := s.rows[k]
+ if !ok || v.expires <= time.Now().Unix() {
+ return nil, ErrMissing
+ }
+ delete(s.rows, k)
+ if strings.HasPrefix(k, "refresh:") {
+ s.rows["used-refresh:"+strings.TrimPrefix(k, "refresh:")] = v
+ }
+ return append([]byte{}, v.value...), nil
+}
+func (s *memoryStore) Delete(_ context.Context, k string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.rows, k)
+ return nil
+}
+func (s *memoryStore) Families(_ context.Context, owner string) ([][]byte, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ var out [][]byte
+ for k, v := range s.rows {
+ if strings.HasPrefix(k, "family:") && v.owner == owner {
+ out = append(out, v.value)
+ }
+ }
+ return out, nil
+}
+func setup(t *testing.T) (*Provider, *memoryStore) {
+ t.Helper()
+ key, e := rsa.GenerateKey(rand.Reader, 2048)
+ if e != nil {
+ t.Fatal(e)
+ }
+ store := &memoryStore{rows: map[string]entry{}}
+ p, e := New(Config{Issuer: "https://api.lmm.best/oidc", Key: key, Store: store, Clients: []Client{{ID: "coweft-web", Name: "CoWeft", RedirectURIs: []string{"https://forum.example/auth/callback"}, Resources: []string{"https://forum.example/mcp"}, Scopes: knownScopes, Controller: "human"}}, Resources: []Resource{{ID: "coweft", URI: "https://forum.example/mcp", Secret: strings.Repeat("s", 32)}}, BrowserIdentity: func(context.Context, *http.Request) (Identity, error) {
+ return Identity{Subject: "lmm:7", Name: "member", SessionID: "session", SessionVersion: 1, AuthVersion: 1}, nil
+ }, ValidateIdentity: func(context.Context, Identity) error { return nil }})
+ if e != nil {
+ t.Fatal(e)
+ }
+ return p, store
+}
+func query() url.Values {
+ return url.Values{"client_id": {"coweft-web"}, "redirect_uri": {"https://forum.example/auth/callback"}, "resource": {"https://forum.example/mcp"}, "scope": {"openid profile coweft:read coweft:write"}, "response_type": {"code"}, "state": {strings.Repeat("s", 32)}, "nonce": {strings.Repeat("n", 32)}, "code_challenge": {digest(strings.Repeat("v", 43))}, "code_challenge_method": {"S256"}}
+}
+func post(p *Provider, path string, values url.Values, basic bool) *httptest.ResponseRecorder {
+ r := httptest.NewRequest("POST", "https://api.lmm.best"+path, strings.NewReader(values.Encode()))
+ r.TLS = &tls.ConnectionState{}
+ r.RemoteAddr = "127.0.0.1:54321"
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ if basic {
+ r.SetBasicAuth("coweft", strings.Repeat("s", 32))
+ }
+ w := httptest.NewRecorder()
+ p.Handler().ServeHTTP(w, r)
+ return w
+}
+func tokenFor(t *testing.T, p *Provider) map[string]any {
+ t.Helper()
+ a, e := p.parseAuthorization(query())
+ if e != nil {
+ t.Fatal(e)
+ }
+ code := strings.Repeat("c", 43)
+ g := grant{Identity: Identity{Subject: "lmm:7", Name: "member", SessionID: "session", SessionVersion: 1, AuthVersion: 1}, Request: a, Controller: "human", ExpiresAt: time.Now().Add(time.Hour).Unix()}
+ if e = p.put(context.Background(), "code:"+digest(code), g, time.Now().Add(time.Minute).Unix(), "lmm:7"); e != nil {
+ t.Fatal(e)
+ }
+ w := post(p, "/api/oidc/token", url.Values{"client_id": {"coweft-web"}, "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {a.Redirect}, "resource": {a.Resource}, "code_verifier": {strings.Repeat("v", 43)}}, false)
+ if w.Code != 200 {
+ t.Fatal(w.Code, w.Body.String())
+ }
+ var out map[string]any
+ if e = json.Unmarshal(w.Body.Bytes(), &out); e != nil {
+ t.Fatal(e)
+ }
+ return out
+}
+func TestStrictAuthorization(t *testing.T) {
+ p, _ := setup(t)
+ q := query()
+ if _, e := p.parseAuthorization(q); e != nil {
+ t.Fatal(e)
+ }
+ for _, field := range []string{"redirect_uri", "resource", "code_challenge_method", "client_id", "scope"} {
+ bad := query()
+ bad.Set(field, "attacker")
+ if _, e := p.parseAuthorization(bad); e == nil {
+ t.Fatal("accepted invalid", field)
+ }
+ }
+ q.Add("client_id", "coweft-web")
+ if _, e := p.parseAuthorization(q); e == nil {
+ t.Fatal("duplicate parameter accepted")
+ }
+}
+func TestCodeTokenIntrospectionAndRevocation(t *testing.T) {
+ p, _ := setup(t)
+ tokens := tokenFor(t, p)
+ access := tokens["access_token"].(string)
+ if tokens["id_token"] == nil {
+ t.Fatal("missing id_token")
+ }
+ v := url.Values{"token": {access}, "resource": {"https://forum.example/mcp"}}
+ w := post(p, "/api/oidc/introspect", v, true)
+ if w.Code != 200 || !strings.Contains(w.Body.String(), `"active":true`) {
+ t.Fatal(w.Body.String())
+ }
+ if strings.Contains(w.Body.String(), "session_id") {
+ t.Fatal("session leaked")
+ }
+ post(p, "/api/oidc/revoke", url.Values{"token": {access}, "client_id": {"coweft-web"}}, false)
+ w = post(p, "/api/oidc/introspect", v, true)
+ if !strings.Contains(w.Body.String(), `"active":false`) {
+ t.Fatal("revocation ineffective")
+ }
+}
+func TestWrongResourceCannotIntrospect(t *testing.T) {
+ p, _ := setup(t)
+ tokens := tokenFor(t, p)
+ w := post(p, "/api/oidc/introspect", url.Values{"token": {tokens["access_token"].(string)}, "resource": {"https://other.example/mcp"}}, true)
+ if !strings.Contains(w.Body.String(), `"active":false`) {
+ t.Fatal(w.Body.String())
+ }
+}
+func TestRefreshReplayRevokesFamily(t *testing.T) {
+ p, _ := setup(t)
+ tokens := tokenFor(t, p)
+ v := url.Values{"client_id": {"coweft-web"}, "grant_type": {"refresh_token"}, "refresh_token": {tokens["refresh_token"].(string)}, "resource": {"https://forum.example/mcp"}}
+ first := post(p, "/api/oidc/token", v, false)
+ if first.Code != 200 {
+ t.Fatal(first.Body.String())
+ }
+ var fresh map[string]any
+ json.Unmarshal(first.Body.Bytes(), &fresh)
+ second := post(p, "/api/oidc/token", v, false)
+ if second.Code != 400 {
+ t.Fatal("replay accepted")
+ }
+ _, _, e := p.access(context.Background(), fresh["access_token"].(string))
+ if e == nil {
+ t.Fatal("replay did not revoke new token")
+ }
+}
+func TestCurrentSessionCheckedForEveryAccess(t *testing.T) {
+ p, _ := setup(t)
+ tokens := tokenFor(t, p)
+ p.config.ValidateIdentity = func(context.Context, Identity) error { return ErrDenied }
+ _, _, e := p.access(context.Background(), tokens["access_token"].(string))
+ if e == nil {
+ t.Fatal("revoked session accepted")
+ }
+}
+func TestSigningKeyAndDiscovery(t *testing.T) {
+ p, _ := setup(t)
+ w := httptest.NewRecorder()
+ p.discovery(w)
+ if !strings.Contains(w.Body.String(), `"issuer":"https://api.lmm.best/oidc"`) {
+ t.Fatal(w.Body.String())
+ }
+ if strings.Contains(w.Body.String(), "registration_endpoint") {
+ t.Fatal("dynamic registration advertised")
+ }
+ w = httptest.NewRecorder()
+ p.jwks(w)
+ if strings.Contains(w.Body.String(), `"d":`) {
+ t.Fatal("private key leaked")
+ }
+}
+func TestLoopbackRedirectPinsHostAndPath(t *testing.T) {
+ c := Client{Loopback: true, RedirectURIs: []string{"http://127.0.0.1/callback"}}
+ if !redirectMatches(c, "http://127.0.0.1:49152/callback") {
+ t.Fatal("valid loopback rejected")
+ }
+ for _, u := range []string{"http://localhost:49152/callback", "http://127.0.0.1:49152/evil", "http://127.0.0.1.attacker.example:49152/callback"} {
+ if redirectMatches(c, u) {
+ t.Fatal("unsafe redirect accepted", u)
+ }
+ }
+}
+func TestMissingTLSRejected(t *testing.T) {
+ p, _ := setup(t)
+ r := httptest.NewRequest("GET", "http://api.lmm.best/api/oidc/jwks", nil)
+ w := httptest.NewRecorder()
+ p.Handler().ServeHTTP(w, r)
+ if w.Code != 400 {
+ t.Fatal("plaintext endpoint accepted")
+ }
+}
+func TestAtomicTake(t *testing.T) {
+ _, s := setup(t)
+ ctx := context.Background()
+ s.Set(ctx, "refresh:a", []byte(`{"family_id":"x"}`), time.Now().Add(time.Hour).Unix(), "u")
+ var wg sync.WaitGroup
+ success := make(chan bool, 20)
+ for i := 0; i < 20; i++ {
+ wg.Add(1)
+ go func() { defer wg.Done(); _, e := s.Take(ctx, "refresh:a"); success <- e == nil }()
+ }
+ wg.Wait()
+ close(success)
+ n := 0
+ for ok := range success {
+ if ok {
+ n++
+ }
+ }
+ if n != 1 {
+ t.Fatal("consumed", n, "times")
+ }
+ if _, e := s.Get(ctx, "used-refresh:a"); e != nil {
+ t.Fatal("atomic replay marker absent")
+ }
+}
diff --git a/apps/api-go/oidcprovider/tokens.go b/apps/api-go/oidcprovider/tokens.go
new file mode 100644
index 000000000..1a7a2dd73
--- /dev/null
+++ b/apps/api-go/oidcprovider/tokens.go
@@ -0,0 +1,272 @@
+package oidcprovider
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+)
+
+func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
+
+type capability struct {
+ FamilyID string `json:"family_id"`
+ ExpiresAt int64 `json:"expires_at"`
+}
+
+func validVerifier(v string) bool {
+ if len(v) < 43 || len(v) > 128 {
+ return false
+ }
+ for _, r := range v {
+ if !strings.ContainsRune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~", r) {
+ return false
+ }
+ }
+ return true
+}
+func (p *Provider) token(w http.ResponseWriter, r *http.Request) {
+ if len(r.Header.Values("Authorization")) != 0 {
+ fail(w, 400, "invalid_client")
+ return
+ }
+ f, e := parseForm(w, r)
+ if e != nil {
+ fail(w, 400, "invalid_request")
+ return
+ }
+ client, ok := p.clients[f.Get("client_id")]
+ if !ok {
+ fail(w, 400, "invalid_client")
+ return
+ }
+ var g grant
+ switch f.Get("grant_type") {
+ case "authorization_code":
+ code := f.Get("code")
+ if len(code) != 43 {
+ fail(w, 400, "invalid_grant")
+ return
+ }
+ if e = p.get(r.Context(), "code:"+digest(code), &g, true); e != nil {
+ var old capability
+ if p.get(r.Context(), "used-code:"+digest(code), &old, false) == nil {
+ var previous grant
+ if p.get(r.Context(), "family:"+old.FamilyID, &previous, false) == nil && previous.Request.ClientID == client.ID {
+ _ = p.config.Store.Delete(r.Context(), "family:"+old.FamilyID)
+ }
+ }
+ fail(w, 400, "invalid_grant")
+ return
+ }
+ if g.Request.ClientID != client.ID || g.Request.Redirect != f.Get("redirect_uri") || g.Request.Resource != f.Get("resource") || !validVerifier(f.Get("code_verifier")) || !same(digest(f.Get("code_verifier")), g.Request.Challenge) || p.config.ValidateIdentity(r.Context(), g.Identity) != nil {
+ fail(w, 400, "invalid_grant")
+ return
+ }
+ g.ID, e = random()
+ if e != nil {
+ fail(w, 503, "server_error")
+ return
+ }
+ if e = p.put(r.Context(), "family:"+g.ID, g, g.ExpiresAt, g.Identity.Subject); e != nil {
+ fail(w, 503, "server_error")
+ return
+ }
+ if e = p.put(r.Context(), "used-code:"+digest(code), capability{g.ID, g.ExpiresAt}, g.ExpiresAt, g.Identity.Subject); e != nil {
+ _ = p.config.Store.Delete(r.Context(), "family:"+g.ID)
+ fail(w, 503, "server_error")
+ return
+ }
+ case "refresh_token":
+ raw := f.Get("refresh_token")
+ if !strings.HasPrefix(raw, "lmm_r_") || len(raw) != 49 {
+ fail(w, 400, "invalid_grant")
+ return
+ }
+ var cap capability
+ if e = p.get(r.Context(), "refresh:"+digest(raw), &cap, true); e != nil {
+ var used capability
+ if p.get(r.Context(), "used-refresh:"+digest(raw), &used, false) == nil {
+ var old grant
+ if p.get(r.Context(), "family:"+used.FamilyID, &old, false) == nil && old.Request.ClientID == client.ID {
+ _ = p.config.Store.Delete(r.Context(), "family:"+used.FamilyID)
+ }
+ }
+ fail(w, 400, "invalid_grant")
+ return
+ }
+ if e = p.get(r.Context(), "family:"+cap.FamilyID, &g, false); e != nil || g.Request.ClientID != client.ID || g.Request.Resource != f.Get("resource") || p.config.ValidateIdentity(r.Context(), g.Identity) != nil {
+ fail(w, 400, "invalid_grant")
+ return
+ }
+ // Rotation never recreates a family. A racing replay permanently revokes it.
+ if e = p.put(r.Context(), "used-refresh:"+digest(raw), cap, g.ExpiresAt, g.Identity.Subject); e != nil {
+ fail(w, 503, "server_error")
+ return
+ }
+ if f.Get("scope") != "" && f.Get("scope") != strings.Join(g.Request.Scopes, " ") {
+ fail(w, 400, "invalid_scope")
+ return
+ }
+ default:
+ fail(w, 400, "unsupported_grant_type")
+ return
+ }
+ if g.ExpiresAt <= time.Now().Unix() {
+ fail(w, 400, "invalid_grant")
+ return
+ }
+ response, e := p.issue(r.Context(), g, f.Get("grant_type") == "authorization_code")
+ if e != nil {
+ fail(w, 503, "server_error")
+ return
+ }
+ jsonResponse(w, 200, response)
+}
+func (p *Provider) issue(ctx context.Context, g grant, idToken bool) (map[string]any, error) {
+ a, e := random()
+ if e != nil {
+ return nil, e
+ }
+ b, e := random()
+ if e != nil {
+ return nil, e
+ }
+ access := "lmm_o_" + a
+ refresh := "lmm_r_" + b
+ expires := time.Now().Add(10 * time.Minute).Unix()
+ if expires > g.ExpiresAt {
+ expires = g.ExpiresAt
+ }
+ idle := time.Now().Add(7 * 24 * time.Hour).Unix()
+ if idle > g.ExpiresAt {
+ idle = g.ExpiresAt
+ }
+ if e = p.put(ctx, "access:"+digest(access), capability{g.ID, expires}, expires, g.Identity.Subject); e != nil {
+ return nil, e
+ }
+ if e = p.put(ctx, "refresh:"+digest(refresh), capability{g.ID, idle}, idle, g.Identity.Subject); e != nil {
+ return nil, e
+ }
+ out := map[string]any{"access_token": access, "token_type": "Bearer", "expires_in": expires - time.Now().Unix(), "refresh_token": refresh, "scope": strings.Join(g.Request.Scopes, " ")}
+ if idToken && contains(g.Request.Scopes, "openid") {
+ sum := sha256.Sum256([]byte(access))
+ claims := map[string]any{"iss": p.config.Issuer, "sub": g.Identity.Subject, "aud": g.Request.ClientID, "iat": time.Now().Unix(), "exp": expires, "nonce": g.Request.Nonce, "at_hash": base64.RawURLEncoding.EncodeToString(sum[:16])}
+ if contains(g.Request.Scopes, "profile") {
+ claims["name"] = g.Identity.Name
+ claims["preferred_username"] = g.Identity.Name
+ }
+ token, e := p.sign(claims)
+ if e != nil {
+ return nil, e
+ }
+ out["id_token"] = token
+ }
+ return out, nil
+}
+func (p *Provider) access(ctx context.Context, raw string) (grant, capability, error) {
+ var g grant
+ var cap capability
+ if !strings.HasPrefix(raw, "lmm_o_") || len(raw) != 49 {
+ return g, cap, ErrDenied
+ }
+ if e := p.get(ctx, "access:"+digest(raw), &cap, false); e != nil {
+ return g, cap, e
+ }
+ if cap.ExpiresAt <= time.Now().Unix() {
+ return g, cap, ErrDenied
+ }
+ if e := p.get(ctx, "family:"+cap.FamilyID, &g, false); e != nil {
+ return g, cap, e
+ }
+ if g.ExpiresAt <= time.Now().Unix() || p.config.ValidateIdentity(ctx, g.Identity) != nil {
+ return g, cap, ErrDenied
+ }
+ return g, cap, nil
+}
+func bearer(r *http.Request) (string, bool) {
+ values := r.Header.Values("Authorization")
+ if len(values) != 1 || !strings.HasPrefix(values[0], "Bearer ") {
+ return "", false
+ }
+ raw := strings.TrimPrefix(values[0], "Bearer ")
+ return raw, len(raw) == 49
+}
+func (p *Provider) userinfo(w http.ResponseWriter, r *http.Request) {
+ raw, ok := bearer(r)
+ if !ok {
+ w.Header().Set("WWW-Authenticate", "Bearer")
+ fail(w, 401, "invalid_token")
+ return
+ }
+ g, _, e := p.access(r.Context(), raw)
+ if e != nil || !contains(g.Request.Scopes, "openid") {
+ fail(w, 401, "invalid_token")
+ return
+ }
+ out := map[string]any{"sub": g.Identity.Subject}
+ if contains(g.Request.Scopes, "profile") {
+ out["name"] = g.Identity.Name
+ out["preferred_username"] = g.Identity.Name
+ }
+ jsonResponse(w, 200, out)
+}
+func (p *Provider) introspect(w http.ResponseWriter, r *http.Request) {
+ id, secret, ok := r.BasicAuth()
+ resource, found := p.resources[id]
+ if !ok || !found || !same(secret, resource.Secret) {
+ w.Header().Set("WWW-Authenticate", `Basic realm="LMM resource introspection"`)
+ fail(w, 401, "invalid_client")
+ return
+ }
+ f, e := parseForm(w, r)
+ if e != nil {
+ fail(w, 400, "invalid_request")
+ return
+ }
+ if f.Get("resource") != resource.URI {
+ jsonResponse(w, 200, map[string]bool{"active": false})
+ return
+ }
+ g, cap, e := p.access(r.Context(), f.Get("token"))
+ if e != nil || g.Request.Resource != resource.URI {
+ jsonResponse(w, 200, map[string]bool{"active": false})
+ return
+ }
+ out := map[string]any{"active": true, "iss": p.config.Issuer, "sub": g.Identity.Subject, "aud": g.Request.Resource, "client_id": g.Request.ClientID, "scope": strings.Join(g.Request.Scopes, " "), "exp": cap.ExpiresAt, "controller": g.Controller, "grant_id": g.ID, "token_type": "Bearer"}
+ if contains(g.Request.Scopes, "profile") {
+ out["name"] = g.Identity.Name
+ }
+ jsonResponse(w, 200, out)
+}
+func (p *Provider) revoke(w http.ResponseWriter, r *http.Request) {
+ f, e := parseForm(w, r)
+ if e != nil || len(r.Header.Values("Authorization")) != 0 {
+ fail(w, 400, "invalid_request")
+ return
+ }
+ client, ok := p.clients[f.Get("client_id")]
+ if !ok {
+ fail(w, 400, "invalid_client")
+ return
+ }
+ raw := f.Get("token")
+ var cap capability
+ prefix := "access:"
+ if strings.HasPrefix(raw, "lmm_r_") {
+ prefix = "refresh:"
+ }
+ if p.get(r.Context(), prefix+digest(raw), &cap, false) == nil {
+ var g grant
+ if p.get(r.Context(), "family:"+cap.FamilyID, &g, false) == nil && g.Request.ClientID == client.ID {
+ if e = p.config.Store.Delete(r.Context(), "family:"+g.ID); e != nil {
+ fail(w, 503, "server_error")
+ return
+ }
+ }
+ }
+ w.WriteHeader(200)
+}
diff --git a/apps/api-go/router/oauth_server.go b/apps/api-go/router/oauth_server.go
index 5108ff48f..ab3fca58e 100644
--- a/apps/api-go/router/oauth_server.go
+++ b/apps/api-go/router/oauth_server.go
@@ -2,16 +2,14 @@ package router
import (
"fmt"
-
"github.com/LIghtJUNction/api.lmm.best/controller"
"github.com/LIghtJUNction/api.lmm.best/model"
"github.com/LIghtJUNction/api.lmm.best/service"
"github.com/gin-gonic/gin"
)
-// SetOAuthServerRouter has no overlap with /api/oauth/:provider. All OAuth HTTP
-// handlers have their own limits, deadlines and security headers, not the
-// dashboard/JWT API group or anonymous model-relay authentication middleware.
+// Native OAuth and the subproject OIDC issuer have separate discovery paths.
+// Existing Pi/DSH/CLI clients retain their original endpoints and scope policy.
func SetOAuthServerRouter(router *gin.Engine) error {
config, err := service.OAuthServerConfigFromEnv()
if err != nil {
@@ -22,12 +20,13 @@ func SetOAuthServerRouter(router *gin.Engine) error {
return fmt.Errorf("initialize OAuth server: %w", err)
}
MountOAuthServerRoutes(router, integration)
+ if err := mountSubprojectOIDC(router); err != nil {
+ return fmt.Errorf("initialize subproject OIDC: %w", err)
+ }
return nil
}
-// MountOAuthServerRoutes also mounts disabled 404s so a SPA fallback cannot
-// accidentally pretend to be OAuth discovery. Explicit injection supports
-// isolated HTTP integration tests; production always uses startup configuration.
+// Disabled endpoints return 404 instead of accidentally serving the SPA.
func MountOAuthServerRoutes(router *gin.Engine, integration *service.OAuthIntegration) {
h := controller.NewOAuthHTTP(integration)
discovery := h.Guard(120, "metadata")
diff --git a/apps/api-go/router/oidc_provider.go b/apps/api-go/router/oidc_provider.go
new file mode 100644
index 000000000..1485ae213
--- /dev/null
+++ b/apps/api-go/router/oidc_provider.go
@@ -0,0 +1,35 @@
+package router
+
+import (
+ "github.com/LIghtJUNction/api.lmm.best/model"
+ "github.com/LIghtJUNction/api.lmm.best/service"
+ "github.com/gin-gonic/gin"
+ "net/http"
+)
+
+func mountSubprojectOIDC(router *gin.Engine) error {
+ provider, err := service.ConfigureSubprojectOIDC(model.DB)
+ if err != nil {
+ return err
+ }
+ handler := http.Handler(http.NotFoundHandler())
+ attestation := http.Handler(http.NotFoundHandler())
+ browser := http.Handler(http.NotFoundHandler())
+ if provider != nil {
+ handler = provider.Handler()
+ attestation = provider.AttestationHandler()
+ browser = provider.BrowserEntryHandler()
+ }
+ for _, path := range []string{
+ "/oidc/.well-known/openid-configuration",
+ "/.well-known/oauth-authorization-server/oidc",
+ "/api/oidc/jwks", "/api/oidc/token", "/api/oidc/userinfo", "/api/oidc/introspect", "/api/oidc/revoke",
+ "/api/user/auth/oidc/consent",
+ } {
+ router.Any(path, gin.WrapH(handler))
+ }
+ router.Any("/api/user/auth/oidc/authorize", gin.WrapH(browser))
+ router.Any("/api/user/auth/oidc/grants", gin.WrapH(browser))
+ router.Any("/api/oidc/attest", gin.WrapH(attestation))
+ return nil
+}
diff --git a/apps/api-go/service/oidc_provider.go b/apps/api-go/service/oidc_provider.go
new file mode 100644
index 000000000..c041cd3b7
--- /dev/null
+++ b/apps/api-go/service/oidc_provider.go
@@ -0,0 +1,203 @@
+package service
+
+import (
+ "context"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/LIghtJUNction/api.lmm.best/common"
+ "github.com/LIghtJUNction/api.lmm.best/model"
+ "github.com/LIghtJUNction/api.lmm.best/oidcprovider"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+type oidcRecord struct {
+ Key string `gorm:"primaryKey;size:160"`
+ Value string `gorm:"type:text;not null"`
+ Owner string `gorm:"size:128;index:idx_oidc_owner_expiry"`
+ ExpiresAt int64 `gorm:"index;index:idx_oidc_owner_expiry"`
+}
+
+func (oidcRecord) TableName() string { return "lmm_oidc_records" }
+
+type oidcStore struct{ db *gorm.DB }
+
+func (s oidcStore) Set(ctx context.Context, key string, value []byte, expires int64, owner string) error {
+ row := oidcRecord{key, string(value), owner, expires}
+ return s.db.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "key"}}, DoUpdates: clause.AssignmentColumns([]string{"value", "owner", "expires_at"})}).Create(&row).Error
+}
+func (s oidcStore) Get(ctx context.Context, key string) ([]byte, error) {
+ var row oidcRecord
+ e := s.db.WithContext(ctx).Where("key = ? AND expires_at > ?", key, time.Now().Unix()).First(&row).Error
+ if errors.Is(e, gorm.ErrRecordNotFound) {
+ return nil, oidcprovider.ErrMissing
+ }
+ return []byte(row.Value), e
+}
+func (s oidcStore) Take(ctx context.Context, key string) ([]byte, error) {
+ var value []byte
+ e := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ var row oidcRecord
+ e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("key = ? AND expires_at > ?", key, time.Now().Unix()).First(&row).Error
+ if errors.Is(e, gorm.ErrRecordNotFound) {
+ return oidcprovider.ErrMissing
+ }
+ if e != nil {
+ return e
+ }
+ deleted := tx.Where("key = ? AND value = ?", key, row.Value).Delete(&oidcRecord{})
+ if deleted.Error != nil {
+ return deleted.Error
+ }
+ if deleted.RowsAffected != 1 {
+ return oidcprovider.ErrMissing
+ }
+ // Publish the rotation tombstone in the SAME transaction as consumption.
+ // Otherwise a simultaneous replay could arrive before the handler records it.
+ if strings.HasPrefix(key, "refresh:") {
+ marker := oidcRecord{Key: "used-refresh:" + strings.TrimPrefix(key, "refresh:"), Value: row.Value, Owner: row.Owner, ExpiresAt: row.ExpiresAt}
+ if e := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&marker).Error; e != nil {
+ return e
+ }
+ }
+ value = []byte(row.Value)
+ return nil
+ })
+ return value, e
+}
+func (s oidcStore) Delete(ctx context.Context, key string) error {
+ return s.db.WithContext(ctx).Where("key = ?", key).Delete(&oidcRecord{}).Error
+}
+func (s oidcStore) Families(ctx context.Context, owner string) ([][]byte, error) {
+ var rows []oidcRecord
+ e := s.db.WithContext(ctx).Where("owner = ? AND key LIKE ? AND expires_at > ?", owner, "family:%", time.Now().Unix()).Order("expires_at DESC").Limit(100).Find(&rows).Error
+ values := make([][]byte, 0, len(rows))
+ for _, row := range rows {
+ values = append(values, []byte(row.Value))
+ }
+ return values, e
+}
+
+// ConfigureSubprojectOIDC uses the SAME LMM user/session tables and verified
+// refresh cookie as native OAuth. No group, account level or paid status is
+// imported into a subproject's community identity or governance permissions.
+func ConfigureSubprojectOIDC(db *gorm.DB) (*oidcprovider.Provider, error) {
+ if os.Getenv("LMM_OIDC_ENABLED") != "true" {
+ return nil, nil
+ }
+ if db == nil {
+ return nil, fmt.Errorf("OIDC database is unavailable")
+ }
+ path := os.Getenv("LMM_OIDC_SIGNING_KEY_FILE")
+ if path == "" {
+ return nil, fmt.Errorf("LMM_OIDC_SIGNING_KEY_FILE is required")
+ }
+ encoded, e := os.ReadFile(path)
+ if e != nil {
+ return nil, fmt.Errorf("read OIDC signing key: %w", e)
+ }
+ block, rest := pem.Decode(encoded)
+ if block == nil || len(strings.TrimSpace(string(rest))) != 0 {
+ return nil, fmt.Errorf("expected exactly one PEM private key")
+ }
+ var key *rsa.PrivateKey
+ if block.Type == "RSA PRIVATE KEY" {
+ key, e = x509.ParsePKCS1PrivateKey(block.Bytes)
+ } else {
+ var parsed any
+ parsed, e = x509.ParsePKCS8PrivateKey(block.Bytes)
+ if e == nil {
+ var ok bool
+ key, ok = parsed.(*rsa.PrivateKey)
+ if !ok {
+ return nil, fmt.Errorf("OIDC key must be RSA")
+ }
+ }
+ }
+ if e != nil {
+ return nil, e
+ }
+ var clients []oidcprovider.Client
+ var resources []oidcprovider.Resource
+ if e = json.Unmarshal([]byte(os.Getenv("LMM_OIDC_CLIENTS")), &clients); e != nil {
+ return nil, fmt.Errorf("LMM_OIDC_CLIENTS: %w", e)
+ }
+ if e = json.Unmarshal([]byte(os.Getenv("LMM_OIDC_RESOURCES")), &resources); e != nil {
+ return nil, fmt.Errorf("LMM_OIDC_RESOURCES: %w", e)
+ }
+ for i := range resources {
+ if resources[i].SecretEnv == "" {
+ return nil, fmt.Errorf("resource secret_env is required")
+ }
+ resources[i].Secret = os.Getenv(resources[i].SecretEnv)
+ }
+ var networks []*net.IPNet
+ for _, raw := range strings.Split(os.Getenv("LMM_OIDC_TRUSTED_PROXY_CIDRS"), ",") {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ continue
+ }
+ _, network, e := net.ParseCIDR(raw)
+ if e != nil {
+ return nil, e
+ }
+ ones, _ := network.Mask.Size()
+ if ones == 0 {
+ return nil, fmt.Errorf("do not trust every address as an OIDC proxy")
+ }
+ networks = append(networks, network)
+ }
+ if e = db.AutoMigrate(&oidcRecord{}); e != nil {
+ return nil, e
+ }
+ if e = db.Where("expires_at <= ?", time.Now().Unix()).Delete(&oidcRecord{}).Error; e != nil {
+ return nil, e
+ }
+ browser := &OAuthIntegration{DB: db}
+ issuer := os.Getenv("LMM_OIDC_ISSUER")
+ if issuer == "" {
+ issuer = "https://api.lmm.best/oidc"
+ }
+ return oidcprovider.New(oidcprovider.Config{Issuer: issuer, Clients: clients, Resources: resources, Key: key, Store: oidcStore{db}, TrustedProxies: networks,
+ BrowserIdentity: func(ctx context.Context, r *http.Request) (oidcprovider.Identity, error) {
+ current, user, e := browser.BrowserIdentity(ctx, r)
+ if e != nil {
+ return oidcprovider.Identity{}, e
+ }
+ return oidcprovider.Identity{Subject: "lmm:" + strconv.FormatInt(current.UserID, 10), Name: user.Username, SessionID: current.SessionID, SessionVersion: current.SessionVersion, AuthVersion: current.AuthVersion}, nil
+ },
+ ValidateIdentity: func(ctx context.Context, identity oidcprovider.Identity) error {
+ if !strings.HasPrefix(identity.Subject, "lmm:") {
+ return oidcprovider.ErrDenied
+ }
+ id, e := strconv.ParseInt(strings.TrimPrefix(identity.Subject, "lmm:"), 10, 64)
+ if e != nil || id <= 0 {
+ return oidcprovider.ErrDenied
+ }
+ var session model.UserSession
+ if e = db.WithContext(ctx).Where("sid = ?", identity.SessionID).First(&session).Error; e != nil {
+ return oidcprovider.ErrDenied
+ }
+ now := time.Now().Unix()
+ if int64(session.UserID) != id || session.Status != model.UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= now || session.Version != identity.SessionVersion || session.UserAuthVersion != identity.AuthVersion {
+ return oidcprovider.ErrDenied
+ }
+ var user model.User
+ if e = db.WithContext(ctx).Where("id = ?", id).First(&user).Error; e != nil || user.Status != common.UserStatusEnabled || user.AuthVersion != identity.AuthVersion {
+ return oidcprovider.ErrDenied
+ }
+ return enforceSessionAutoLogout(&session, user.GetSetting().IsSessionAutoLogoutEnabled(), now)
+ },
+ })
+}
diff --git a/apps/coweft b/apps/coweft
new file mode 160000
index 000000000..18c48476c
--- /dev/null
+++ b/apps/coweft
@@ -0,0 +1 @@
+Subproject commit 18c48476cef1eed869e1b32363576b2eed89514e
diff --git a/docs/coweft-identity.md b/docs/coweft-identity.md
new file mode 100644
index 000000000..d4ae40ebe
--- /dev/null
+++ b/docs/coweft-identity.md
@@ -0,0 +1,50 @@
+# CoWeft subproject identity contract
+
+CoWeft is a resource server and OIDC relying party. LMM owns authentication, stable subjects, consent, delegated authorization, signing keys and revocation. The child is pinned at `apps/coweft` as a Git submodule; it does not create a second login system. The existing native OAuth issuer and Pi/DSH/Codewhale endpoints are unchanged.
+
+## Endpoints
+
+The issuer is `https://api.lmm.best/oidc`. OIDC discovery is `/oidc/.well-known/openid-configuration`; RFC 8414 discovery is `/.well-known/oauth-authorization-server/oidc`. Browser authorization and grant management live beneath `/api/user/auth/oidc/` so the existing refresh cookie path does not need widening. Machine token, JWKS, userinfo, introspection, revocation and public-content attestation endpoints live beneath `/api/oidc/`.
+
+Only authorization-code + S256 PKCE is accepted. ID tokens are RS256, with client audience, nonce and access-token hash. Access and rotating refresh tokens are opaque; only their hashes are stored as lookup keys. Refresh-token consumption and its replay marker are atomic. Every introspection checks both the grant family and the current LMM user session. Disabled accounts, revoked sessions, session-version changes and explicit grant revocation are rejected at resource access. Public clients have no embedded shared secret. Each resource server has a separate introspection credential and cannot inspect another resource's audience.
+
+Subjects are `lmm:`. Never recycle user IDs. Username, group, VIP status and spending are not identity keys or forum voting weights. Controller provenance (`human` or `agent`) comes from trusted client registration, not a request header. Native-agent clients must be registered explicitly; arbitrary dynamic registration is not offered. Controller provenance identifies the authorized submission channel, not whether text was actually written by a human.
+
+## Browser login and consent
+
+The browser entry bridge validates the complete client/callback/resource request before rendering anything. A cross-site arrival may lack LMM's existing SameSite=Strict refresh cookie. The user can continue through a same-site link; if not logged in, the existing LMM login opens in a separate tab while the authorization request stays in the original tab. After login, continuing presents the consent screen.
+
+This does not widen the refresh-cookie path, invent another password login, or depend on a SPA `redirect` parameter. Consent is explicit, bound to the current LMM session, a short-lived transaction, a host-scoped cookie and a CSRF token. Denial returns an OAuth error to the registered callback. `/api/user/auth/oidc/grants` lists and revokes a user's active subproject grants. Verify the entire flow against the actual TLS proxy and frontend before enabling public users.
+
+## Scope policy
+
+`openid profile` identifies the user and shares the display name. `coweft:read`, `coweft:write`, `coweft:propose` and `coweft:vote` are separate permissions. All forum scopes require `coweft:read`. Model execution and spending are NOT included in this issuer's scopes. An agent acts only within its consented client grant. Human and agent use the same subject, not separate governance identities.
+
+## Public federation receipts
+
+`POST /api/oidc/attest` requires TWO independent approvals: the registered resource's HTTP Basic credential and an active user access token in a bounded JSON body. The JSON contains `token`, `digest` and `purpose`; the only supported purpose is `public-thread-v1`. The token must have `coweft:write` and an audience equal to the authenticating resource's registered URI. A user's bearer token alone cannot impersonate an origin node's publication approval.
+
+The result is an RS256-signed public receipt with dedicated type `coweft-event+jwt`, audience `urn:coweft:public-thread-v1`, source resource, subject, controller and SHA-256 content digest. It is not an access token or ID token, contains no bearer credential and cannot authorize an API call. Peers receive the public envelope, never either credential used to obtain it. A receipt attests authorized publication at that time, not the truth of the content or unique natural-person authorship.
+
+Public receipts intentionally outlive login grants. Revoking a login does not erase content already distributed. Historical verification keys must remain available before planned key rotation. This first profile replicates public thread snapshots; it is not ActivityPub, globally replicated voting, migration or guaranteed remote deletion.
+
+## Enable on the parent
+
+Default: disabled. Generate an RSA key outside the repository, restrict its filesystem permissions and mount it read-only into the existing Go API service. For example:
+
+```sh
+openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out /run/secrets/lmm-oidc.pem
+chmod 600 /run/secrets/lmm-oidc.pem
+```
+
+Set `LMM_OIDC_ENABLED=true`, `LMM_OIDC_SIGNING_KEY_FILE=/run/secrets/lmm-oidc.pem`, `LMM_OIDC_ISSUER=https://api.lmm.best/oidc` and the registrations from `packaging/common/lmm-api/lmm-oidc.env.example`. Use the actual CoWeft HTTPS origin in both callback and resource. Generate a separate random resource credential and configure it in both backends; never expose it in frontend code or native clients. Configure only the actual trusted TLS-proxy CIDRs. Arbitrary forwarding headers are not trusted. Without explicit enablement all new endpoints return 404.
+
+All LMM replicas need the same signing key and persistent database. Overlapping-key rotation is not implemented and must be addressed before a high-availability public rollout. No domain, production secret, live client registration or paid model credential is provisioned by this change.
+
+## Verification and rollout limits
+
+The provider suite covers strict authorization requests, registered redirects, PKCE, browser consent, code reuse, refresh replay, current-session checks, wrong-resource rejection, revocation and resource-plus-user publication approval. CoWeft CI consumes a real Go-signed receipt and verifies it in Rust; this is cross-language protocol verification, not a claim that production browser login or a multi-host deployment has been exercised.
+
+Persistent records are pruned on startup; long-running deployments should also schedule expiry cleanup. Browser grant management currently shows at most 100 recent families. No password grant, implicit flow, arbitrary dynamic registration, DPoP, SAML, email disclosure, back-channel logout or automatic trust of external identities is advertised. This implementation is not a certified OpenID Provider. Run the complete suites and obtain an independent review of this new authorization boundary before public enablement.
+
+OAuth authenticates accounts, not unique natural people. CoWeft prevents a human and authorized agents within one account from multiplying votes; it does not claim Sybil-proof personhood or complete decentralized identity.
diff --git a/packaging/common/lmm-api/lmm-oidc.env.example b/packaging/common/lmm-api/lmm-oidc.env.example
new file mode 100644
index 000000000..c2c93f22e
--- /dev/null
+++ b/packaging/common/lmm-api/lmm-oidc.env.example
@@ -0,0 +1,10 @@
+# Apply to the parent Go API service, not the CoWeft frontend.
+# Replace community.example.org with the actual HTTPS CoWeft origin.
+LMM_OIDC_ENABLED=false
+LMM_OIDC_ISSUER=https://api.lmm.best/oidc
+LMM_OIDC_SIGNING_KEY_FILE=/run/secrets/lmm-oidc.pem
+# Explicit TLS terminator source ranges only. Do not use 0.0.0.0/0.
+LMM_OIDC_TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
+COWEFT_RESOURCE_SECRET=
+LMM_OIDC_RESOURCES='[{"id":"coweft","uri":"https://community.example.org/mcp","secret_env":"COWEFT_RESOURCE_SECRET"}]'
+LMM_OIDC_CLIENTS='[{"client_id":"coweft-web","name":"CoWeft · 共织","redirect_uris":["https://community.example.org/auth/callback"],"resources":["https://community.example.org/mcp"],"scopes":["openid","profile","coweft:read","coweft:write","coweft:propose","coweft:vote"],"controller":"human"},{"client_id":"coweft-agent","name":"CoWeft AI companion","redirect_uris":["http://127.0.0.1/oauth/coweft/callback"],"resources":["https://community.example.org/mcp"],"scopes":["openid","profile","coweft:read","coweft:write","coweft:propose","coweft:vote"],"controller":"agent","loopback":true}]'