From 5d2f943469d4d905657b5dcc7f15dddeb130b7ff Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 19:09:53 +0800 Subject: [PATCH 01/10] feat(identity): add subproject OIDC grants and register CoWeft submodule --- .github/workflows/coweft-identity.yml | 22 +++ .gitmodules | 3 + apps/api-go/oidcprovider/browser.go | 83 +++++++++++ apps/api-go/oidcprovider/provider.go | 160 ++++++++++++++++++++++ apps/api-go/oidcprovider/provider_test.go | 37 +++++ apps/api-go/oidcprovider/tokens.go | 79 +++++++++++ apps/api-go/router/oauth_server.go | 69 +++++----- apps/api-go/router/oidc_provider.go | 20 +++ apps/api-go/service/oidc_provider.go | 97 +++++++++++++ apps/coweft | 1 + deploy/coweft/oidc.env.example | 10 ++ docs/coweft-identity.md | 29 ++++ 12 files changed, 572 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/coweft-identity.yml create mode 100644 apps/api-go/oidcprovider/browser.go create mode 100644 apps/api-go/oidcprovider/provider.go create mode 100644 apps/api-go/oidcprovider/provider_test.go create mode 100644 apps/api-go/oidcprovider/tokens.go create mode 100644 apps/api-go/router/oidc_provider.go create mode 100644 apps/api-go/service/oidc_provider.go create mode 160000 apps/coweft create mode 100644 deploy/coweft/oidc.env.example create mode 100644 docs/coweft-identity.md 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/browser.go b/apps/api-go/oidcprovider/browser.go new file mode 100644 index 000000000..fc723b1e2 --- /dev/null +++ b/apps/api-go/oidcprovider/browser.go @@ -0,0 +1,83 @@ +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}} · LMM
LMM · 子项目身份与授权

{{.Title}}

当前账号:{{.Name}}

{{if .Manage}}

撤销后,网页与 AI 的下一次资源访问会重新检查授权。已公开的内容不会被删除。

{{range .Grants}}
{{.Request.ClientID}} · {{.Controller}}

{{.Request.Resource}}

{{range .Request.Scopes}}{{.}} {{end}}

{{else}}

没有有效的子项目授权。

{{end}}{{else}}

{{.Client}} 将作为 {{.Controller}} 操作者访问:

{{.Resource}}

登录不会自动授予模型消费权限。人和 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/provider.go b/apps/api-go/oidcprovider/provider.go new file mode 100644 index 000000000..007e3dcac --- /dev/null +++ b/apps/api-go/oidcprovider/provider.go @@ -0,0 +1,160 @@ +// 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..5c1ce6242 --- /dev/null +++ b/apps/api-go/oidcprovider/provider_test.go @@ -0,0 +1,37 @@ +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..44d61f7db --- /dev/null +++ b/apps/api-go/oidcprovider/tokens.go @@ -0,0 +1,79 @@ +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..f26cb0c8f 100644 --- a/apps/api-go/router/oauth_server.go +++ b/apps/api-go/router/oauth_server.go @@ -1,48 +1,41 @@ 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" + "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 { - return fmt.Errorf("configure OAuth server: %w", err) - } - integration, err := service.ConfigureOAuthIntegration(model.DB, config) - if err != nil { - return fmt.Errorf("initialize OAuth server: %w", err) - } - MountOAuthServerRoutes(router, integration) - return nil + config, err := service.OAuthServerConfigFromEnv() + if err != nil { return fmt.Errorf("configure OAuth server: %w", err) } + integration, err := service.ConfigureOAuthIntegration(model.DB, config) + if err != nil { 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") - browser := h.Guard(30, "browser") - tokens := h.Guard(60, "token") - resources := h.Guard(120, "resource") - activity := h.Guard(30, "activity") - router.GET("/.well-known/oauth-authorization-server", discovery, h.Metadata) - router.GET("/.well-known/oauth-protected-resource/api/oauth2", discovery, h.ResourceMetadata) - router.GET("/api/oauth2/authorize", browser, h.Authorize) - router.POST("/api/user/auth/oauth2/continue", browser, h.Continue) - router.POST("/api/user/auth/oauth2/consent", browser, h.Consent) - router.POST("/api/oauth2/token", tokens, h.Token) - router.POST("/api/oauth2/revoke", tokens, h.Revoke) - router.GET("/api/oauth2/catalog", resources, h.Catalog) - router.GET("/api/oauth2/balance", resources, h.Balance) - router.GET("/api/oauth2/usage/activity", activity, h.Activity) + h := controller.NewOAuthHTTP(integration) + discovery := h.Guard(120, "metadata") + browser := h.Guard(30, "browser") + tokens := h.Guard(60, "token") + resources := h.Guard(120, "resource") + activity := h.Guard(30, "activity") + router.GET("/.well-known/oauth-authorization-server", discovery, h.Metadata) + router.GET("/.well-known/oauth-protected-resource/api/oauth2", discovery, h.ResourceMetadata) + router.GET("/api/oauth2/authorize", browser, h.Authorize) + router.POST("/api/user/auth/oauth2/continue", browser, h.Continue) + router.POST("/api/user/auth/oauth2/consent", browser, h.Consent) + router.POST("/api/oauth2/token", tokens, h.Token) + router.POST("/api/oauth2/revoke", tokens, h.Revoke) + router.GET("/api/oauth2/catalog", resources, h.Catalog) + router.GET("/api/oauth2/balance", resources, h.Balance) + router.GET("/api/oauth2/usage/activity", activity, h.Activity) } diff --git a/apps/api-go/router/oidc_provider.go b/apps/api-go/router/oidc_provider.go new file mode 100644 index 000000000..720a9f23a --- /dev/null +++ b/apps/api-go/router/oidc_provider.go @@ -0,0 +1,20 @@ +package router + +import ( + "net/http" + "github.com/LIghtJUNction/api.lmm.best/model" + "github.com/LIghtJUNction/api.lmm.best/service" + "github.com/gin-gonic/gin" +) + +func mountSubprojectOIDC(router *gin.Engine)error { + provider,err:=service.ConfigureSubprojectOIDC(model.DB);if err!=nil{return err} + handler:=http.Handler(http.NotFoundHandler());if provider!=nil{handler=provider.Handler()} + 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/authorize","/api/user/auth/oidc/consent","/api/user/auth/oidc/grants", + } {router.Any(path,gin.WrapH(handler))} + 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..037275a8b --- /dev/null +++ b/apps/api-go/service/oidc_provider.go @@ -0,0 +1,97 @@ +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..3d3eb3d8f --- /dev/null +++ b/apps/coweft @@ -0,0 +1 @@ +Subproject commit 3d3eb3d8f549235a7c0a5a7b246b3bc9bba7b0e4 diff --git a/deploy/coweft/oidc.env.example b/deploy/coweft/oidc.env.example new file mode 100644 index 000000000..c2c93f22e --- /dev/null +++ b/deploy/coweft/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}]' diff --git a/docs/coweft-identity.md b/docs/coweft-identity.md new file mode 100644 index 000000000..350cb9e0e --- /dev/null +++ b/docs/coweft-identity.md @@ -0,0 +1,29 @@ +# CoWeft subproject identity contract + +CoWeft is a resource server. LMM owns authentication, stable subjects, consent, delegation, signing keys and revocation. It does not create a second login system. The existing native OAuth issuer is 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 and revocation 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 hashes are stored. Refresh-token consumption and the replay marker are atomic. Every introspection checks both the family and the current LMM user session. A disabled account, logout, session version change or grant revocation invalidates access without waiting for JWT expiry. 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, user 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. + +## Enable on the parent + +Generate an RSA key outside the repository (`openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out /run/secrets/lmm-oidc.pem`), restrict its filesystem permissions, and mount it read-only into the existing Go API service. All replicas must use the same key; planned overlapping-key rotation is still required before high-availability production rollout. + +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 `deploy/coweft/oidc.env.example`. Set the introspection secret in both services, never in frontend code. Configure only your actual trusted TLS proxy CIDRs; the provider does not trust arbitrary forwarding headers. Without explicit enablement these endpoints return 404. + +Use the actual CoWeft origin in both its registered callback and resource. Open `/api/user/auth/oidc/grants` to revoke access. The existing `/login?redirect=...` return flow must be verified against the deployed frontend before enabling real 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 AI may act only within the consented client grant. Human and agent use the same subject, never an extra vote. + +## Rollout and limits + +This is a new authorization boundary. Deploy behind TLS, run the complete provider and resource-server suites, verify browser login/consent/deny/revoke, and perform independent security review before public production exposure. Configuration is fail-closed. Persistent records are pruned on startup; operators should schedule expiry cleanup for long-running deployments. Browser grant management shows at most 100 recent families. + +This implementation is not a certified OpenID Provider. No password grant, implicit flow, arbitrary dynamic registration, DPoP, SAML, email disclosure, back-channel logout or automatic trust of external identities is advertised. OAuth authenticates accounts, not unique natural people; governance still needs an explicit Sybil-resistance policy. From 831f3e56b45dbe152fa71bf39b41c953a60cff2a Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 19:28:22 +0800 Subject: [PATCH 02/10] feat(identity): attest public CoWeft events and test browser consent flow --- apps/api-go/oidcprovider/attestation.go | 50 +++++++++++++++ apps/api-go/oidcprovider/attestation_test.go | 36 +++++++++++ .../oidcprovider/browser_integration_test.go | 63 +++++++++++++++++++ apps/api-go/oidcprovider/login_bridge.go | 33 ++++++++++ apps/api-go/router/oidc_provider.go | 10 ++- 5 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 apps/api-go/oidcprovider/attestation.go create mode 100644 apps/api-go/oidcprovider/attestation_test.go create mode 100644 apps/api-go/oidcprovider/browser_integration_test.go create mode 100644 apps/api-go/oidcprovider/login_bridge.go diff --git a/apps/api-go/oidcprovider/attestation.go b/apps/api-go/oidcprovider/attestation.go new file mode 100644 index 000000000..3fc28de52 --- /dev/null +++ b/apps/api-go/oidcprovider/attestation.go @@ -0,0 +1,50 @@ +package oidcprovider + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "time" +) + +// AttestationHandler issues a PUBLIC content receipt, never a bearer capability. +// Its dedicated type/audience cannot be used as an ID token or access token. +// A receipt means a currently authorized subject submitted this digest. It is +// NOT proof that a natural person authored, endorsed, or verified the content. +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) + raw,ok:=bearer(r);if !ok {fail(w,401,"invalid_token");return} + g,_,err:=p.access(ctx,raw);if err!=nil {fail(w,401,"invalid_token");return} + if !contains(g.Request.Scopes,"coweft:write"){fail(w,403,"insufficient_scope");return} + if r.Header.Get("Content-Type")!="application/json"{fail(w,400,"invalid_request");return} + var request struct {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} + 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..150e68994 --- /dev/null +++ b/apps/api-go/oidcprovider/attestation_test.go @@ -0,0 +1,36 @@ +package oidcprovider + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestPublicReceiptIsContentBoundAndNotACredential(t *testing.T){ + p,_:=setup(t);tokens:=tokenFor(t,p) + raw:=`{"digest":"`+digest("public content")+`","purpose":"public-thread-v1"}` + request:=httptest.NewRequest("POST","https://api.lmm.best/api/oidc/attest",strings.NewReader(raw)) + request.Header.Set("Content-Type","application/json");request.Header.Set("Authorization","Bearer "+tokens["access_token"].(string)) + 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")} + post(p,"/api/oidc/revoke",url.Values{"token":{tokens["access_token"].(string)},"client_id":{"coweft-web"}},false) + request=httptest.NewRequest("POST","https://api.lmm.best/api/oidc/attest",strings.NewReader(raw));request.Header.Set("Content-Type","application/json");request.Header.Set("Authorization","Bearer "+tokens["access_token"].(string)) + response=httptest.NewRecorder();p.AttestationHandler().ServeHTTP(response,request) + if response.Code!=401{t.Fatal("revoked grant could issue receipt")} +} 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..1a8d3f399 --- /dev/null +++ b/apps/api-go/oidcprovider/browser_integration_test.go @@ -0,0 +1,63 @@ +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")} +} +// CI exports only public keys and a non-bearer receipt. Rust consumes the result +// to verify the actual Go-issued representation across the repository boundary. +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}` + request:=httptest.NewRequest("POST","https://api.lmm.best/api/oidc/attest",strings.NewReader(`{"digest":"`+digest(snapshot)+`","purpose":"public-thread-v1"}`)) + request.Header.Set("Content-Type","application/json");request.Header.Set("Authorization","Bearer "+tokens["access_token"].(string)) + 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) + 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..0d5d5859f --- /dev/null +++ b/apps/api-go/oidcprovider/login_bridge.go @@ -0,0 +1,33 @@ +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/router/oidc_provider.go b/apps/api-go/router/oidc_provider.go index 720a9f23a..bc9fb9a8b 100644 --- a/apps/api-go/router/oidc_provider.go +++ b/apps/api-go/router/oidc_provider.go @@ -9,12 +9,18 @@ import ( func mountSubprojectOIDC(router *gin.Engine)error { provider,err:=service.ConfigureSubprojectOIDC(model.DB);if err!=nil{return err} - handler:=http.Handler(http.NotFoundHandler());if provider!=nil{handler=provider.Handler()} + 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/authorize","/api/user/auth/oidc/consent","/api/user/auth/oidc/grants", + "/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 } From b78455ad6a0db054ed387d77ee94c12df4a2b540 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 19:39:34 +0800 Subject: [PATCH 03/10] fix(identity): require resource approval as well as user grant for publication --- apps/api-go/oidcprovider/attestation.go | 22 ++++++++--------- apps/api-go/oidcprovider/attestation_test.go | 24 +++++++++++-------- .../oidcprovider/browser_integration_test.go | 10 +++----- 3 files changed, 27 insertions(+), 29 deletions(-) diff --git a/apps/api-go/oidcprovider/attestation.go b/apps/api-go/oidcprovider/attestation.go index 3fc28de52..674553fde 100644 --- a/apps/api-go/oidcprovider/attestation.go +++ b/apps/api-go/oidcprovider/attestation.go @@ -13,25 +13,23 @@ import ( "time" ) -// AttestationHandler issues a PUBLIC content receipt, never a bearer capability. -// Its dedicated type/audience cannot be used as an ID token or access token. -// A receipt means a currently authorized subject submitted this digest. It is -// NOT proof that a natural person authored, endorsed, or verified the content. +// 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") + 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} + 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) - raw,ok:=bearer(r);if !ok {fail(w,401,"invalid_token");return} - g,_,err:=p.access(ctx,raw);if err!=nil {fail(w,401,"invalid_token");return} - if !contains(g.Request.Scopes,"coweft:write"){fail(w,403,"insufficient_scope");return} + 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 {Digest string `json:"digest"`;Purpose string `json:"purpose"`} + 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{ diff --git a/apps/api-go/oidcprovider/attestation_test.go b/apps/api-go/oidcprovider/attestation_test.go index 150e68994..c7fe1bec7 100644 --- a/apps/api-go/oidcprovider/attestation_test.go +++ b/apps/api-go/oidcprovider/attestation_test.go @@ -7,18 +7,20 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "net/http" "net/http/httptest" "net/url" "strings" "testing" ) - -func TestPublicReceiptIsContentBoundAndNotACredential(t *testing.T){ - p,_:=setup(t);tokens:=tokenFor(t,p) - raw:=`{"digest":"`+digest("public content")+`","purpose":"public-thread-v1"}` - request:=httptest.NewRequest("POST","https://api.lmm.best/api/oidc/attest",strings.NewReader(raw)) - request.Header.Set("Content-Type","application/json");request.Header.Set("Authorization","Bearer "+tokens["access_token"].(string)) - response:=httptest.NewRecorder();p.AttestationHandler().ServeHTTP(response,request) +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")} @@ -27,10 +29,12 @@ func TestPublicReceiptIsContentBoundAndNotACredential(t *testing.T){ 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 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")} - post(p,"/api/oidc/revoke",url.Values{"token":{tokens["access_token"].(string)},"client_id":{"coweft-web"}},false) - request=httptest.NewRequest("POST","https://api.lmm.best/api/oidc/attest",strings.NewReader(raw));request.Header.Set("Content-Type","application/json");request.Header.Set("Authorization","Bearer "+tokens["access_token"].(string)) + 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_integration_test.go b/apps/api-go/oidcprovider/browser_integration_test.go index 1a8d3f399..19bbe3ff5 100644 --- a/apps/api-go/oidcprovider/browser_integration_test.go +++ b/apps/api-go/oidcprovider/browser_integration_test.go @@ -12,7 +12,6 @@ import ( "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) @@ -34,7 +33,7 @@ func TestBrowserConsentPKCEAndSingleUse(t *testing.T){ 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 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){ @@ -46,15 +45,12 @@ func TestLoginBridgePreservesValidatedFlowWithoutFrontendRedirectAssumption(t *t 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")} } -// CI exports only public keys and a non-bearer receipt. Rust consumes the result -// to verify the actual Go-issued representation across the repository boundary. +// 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}` - request:=httptest.NewRequest("POST","https://api.lmm.best/api/oidc/attest",strings.NewReader(`{"digest":"`+digest(snapshot)+`","purpose":"public-thread-v1"}`)) - request.Header.Set("Content-Type","application/json");request.Header.Set("Authorization","Bearer "+tokens["access_token"].(string)) - response:=httptest.NewRecorder();p.AttestationHandler().ServeHTTP(response,request) + 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) From 063205978f865daf05b677e0a6de1e6f92b78144 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 19:50:29 +0800 Subject: [PATCH 04/10] docs(coweft): pin tested child and finalize identity deployment contract --- apps/coweft | 2 +- docs/coweft-identity.md | 45 ++++++++++++++++++++++++++++++----------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/apps/coweft b/apps/coweft index 3d3eb3d8f..648c58ca2 160000 --- a/apps/coweft +++ b/apps/coweft @@ -1 +1 @@ -Subproject commit 3d3eb3d8f549235a7c0a5a7b246b3bc9bba7b0e4 +Subproject commit 648c58ca2ec59113f54fd2f66fc9ea060585dc9a diff --git a/docs/coweft-identity.md b/docs/coweft-identity.md index 350cb9e0e..c2744be1e 100644 --- a/docs/coweft-identity.md +++ b/docs/coweft-identity.md @@ -1,29 +1,50 @@ # CoWeft subproject identity contract -CoWeft is a resource server. LMM owns authentication, stable subjects, consent, delegation, signing keys and revocation. It does not create a second login system. The existing native OAuth issuer is unchanged. +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 and revocation endpoints live beneath `/api/oidc/`. +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 hashes are stored. Refresh-token consumption and the replay marker are atomic. Every introspection checks both the family and the current LMM user session. A disabled account, logout, session version change or grant revocation invalidates access without waiting for JWT expiry. Public clients have no embedded shared secret. Each resource server has a separate introspection credential and cannot inspect another resource's audience. +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, user 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. +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 -Generate an RSA key outside the repository (`openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out /run/secrets/lmm-oidc.pem`), restrict its filesystem permissions, and mount it read-only into the existing Go API service. All replicas must use the same key; planned overlapping-key rotation is still required before high-availability production rollout. +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: -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 `deploy/coweft/oidc.env.example`. Set the introspection secret in both services, never in frontend code. Configure only your actual trusted TLS proxy CIDRs; the provider does not trust arbitrary forwarding headers. Without explicit enablement these endpoints return 404. +```sh +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out /run/secrets/lmm-oidc.pem +chmod 600 /run/secrets/lmm-oidc.pem +``` -Use the actual CoWeft origin in both its registered callback and resource. Open `/api/user/auth/oidc/grants` to revoke access. The existing `/login?redirect=...` return flow must be verified against the deployed frontend before enabling real users. +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 `deploy/coweft/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. -## Scope policy +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. -`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 AI may act only within the consented client grant. Human and agent use the same subject, never an extra vote. +## Verification and rollout limits -## Rollout and 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. -This is a new authorization boundary. Deploy behind TLS, run the complete provider and resource-server suites, verify browser login/consent/deny/revoke, and perform independent security review before public production exposure. Configuration is fail-closed. Persistent records are pruned on startup; operators should schedule expiry cleanup for long-running deployments. Browser grant management shows at most 100 recent families. +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. -This implementation is not a certified OpenID Provider. No password grant, implicit flow, arbitrary dynamic registration, DPoP, SAML, email disclosure, back-channel logout or automatic trust of external identities is advertised. OAuth authenticates accounts, not unique natural people; governance still needs an explicit Sybil-resistance policy. +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. From ce942578fc049248f4bf2738712f7d13cd9492f8 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 19:54:19 +0800 Subject: [PATCH 05/10] chore(oidc): apply repository formatting before release qualification --- .github/workflows/coweft-format.yml | 43 +++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/coweft-format.yml diff --git a/.github/workflows/coweft-format.yml b/.github/workflows/coweft-format.yml new file mode 100644 index 000000000..2a71d6464 --- /dev/null +++ b/.github/workflows/coweft-format.yml @@ -0,0 +1,43 @@ +name: Format CoWeft identity implementation +on: + push: + branches: ['feat/coweft-oidc'] +permissions: + contents: read +jobs: + format: + # One-time bootstrap housekeeping. Only a trusted same-repository branch; + # no pull-request event and no dependency scripts or application execution. + if: github.repository == 'TokenNotIncluded/api.lmm.best' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: feat/coweft-oidc + - uses: actions/setup-go@v5 + with: + go-version-file: apps/api-go/go.mod + cache: false + - name: Apply Go formatting only to the new identity implementation + shell: bash + run: | + set -euo pipefail + if [ "$(git rev-parse HEAD)" != "$GITHUB_SHA" ]; then + echo 'Branch advanced; do not alter another revision.' + exit 0 + fi + gofmt -w apps/api-go/oidcprovider/*.go \ + apps/api-go/service/oidc_provider.go \ + apps/api-go/router/oidc_provider.go \ + apps/api-go/router/oauth_server.go + git add -- 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 + if git diff --cached --quiet; then exit 0; fi + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m 'style(oidc): apply canonical Go formatting' + git push origin HEAD:refs/heads/feat/coweft-oidc From 6e76fb617921bfa64ea118c35547d160b4c8a6be Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:54:49 +0000 Subject: [PATCH 06/10] style(oidc): apply canonical Go formatting --- apps/api-go/oidcprovider/attestation.go | 126 +++-- apps/api-go/oidcprovider/attestation_test.go | 101 ++-- apps/api-go/oidcprovider/browser.go | 320 +++++++++--- .../oidcprovider/browser_integration_test.go | 157 ++++-- apps/api-go/oidcprovider/login_bridge.go | 72 ++- apps/api-go/oidcprovider/provider.go | 479 +++++++++++++----- apps/api-go/oidcprovider/provider_test.go | 286 +++++++++-- apps/api-go/oidcprovider/tokens.go | 333 +++++++++--- apps/api-go/router/oauth_server.go | 62 ++- apps/api-go/router/oidc_provider.go | 49 +- apps/api-go/service/oidc_provider.go | 268 +++++++--- 11 files changed, 1670 insertions(+), 583 deletions(-) diff --git a/apps/api-go/oidcprovider/attestation.go b/apps/api-go/oidcprovider/attestation.go index 674553fde..d7891a670 100644 --- a/apps/api-go/oidcprovider/attestation.go +++ b/apps/api-go/oidcprovider/attestation.go @@ -1,48 +1,96 @@ package oidcprovider import ( - "context" - "crypto" - "crypto/rand" - "crypto/rsa" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "io" - "net/http" - "time" + "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"}) - }) +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 index c7fe1bec7..7d8f8c807 100644 --- a/apps/api-go/oidcprovider/attestation_test.go +++ b/apps/api-go/oidcprovider/attestation_test.go @@ -1,40 +1,73 @@ package oidcprovider import ( - "context" - "crypto" - "crypto/rsa" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" + "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 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")} +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 index fc723b1e2..c677df496 100644 --- a/apps/api-go/oidcprovider/browser.go +++ b/apps/api-go/oidcprovider/browser.go @@ -1,83 +1,265 @@ package oidcprovider import ( - "html/template" - "net/http" - "net/url" - "strings" - "time" + "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"` + 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 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"` + 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) 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) 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) 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) 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) } -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,"") + +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}} · LMM
LMM · 子项目身份与授权

{{.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) 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}} · LMM
LMM · 子项目身份与授权

{{.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) 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) +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 index 19bbe3ff5..f5a217c80 100644 --- a/apps/api-go/oidcprovider/browser_integration_test.go +++ b/apps/api-go/oidcprovider/browser_integration_test.go @@ -1,59 +1,114 @@ package oidcprovider import ( - "context" - "encoding/base64" - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "os" - "regexp" - "strings" - "testing" + "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 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")} +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)} +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 index 0d5d5859f..6fae3bf54 100644 --- a/apps/api-go/oidcprovider/login_bridge.go +++ b/apps/api-go/oidcprovider/login_bridge.go @@ -1,11 +1,11 @@ package oidcprovider import ( - "context" - "html/template" - "net/http" - "net/url" - "time" + "context" + "html/template" + "net/http" + "net/url" + "time" ) // BrowserEntryHandler keeps the authorization request in the original tab. @@ -13,21 +13,49 @@ import ( // 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()}) - }) +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 登录
`)) + +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 index 007e3dcac..79e6e04fd 100644 --- a/apps/api-go/oidcprovider/provider.go +++ b/apps/api-go/oidcprovider/provider.go @@ -4,24 +4,24 @@ 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" + "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") @@ -30,131 +30,336 @@ 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) + 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"` + 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"` + 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"` + 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} + 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})} + 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 index 5c1ce6242..0f6412bf6 100644 --- a/apps/api-go/oidcprovider/provider_test.go +++ b/apps/api-go/oidcprovider/provider_test.go @@ -1,37 +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" + "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")}} +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 index 44d61f7db..1a7a2dd73 100644 --- a/apps/api-go/oidcprovider/tokens.go +++ b/apps/api-go/oidcprovider/tokens.go @@ -1,79 +1,272 @@ package oidcprovider import ( - "context" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "net/http" - "strings" - "time" + "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 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(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 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) 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 (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 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) 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) +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 f26cb0c8f..ab3fca58e 100644 --- a/apps/api-go/router/oauth_server.go +++ b/apps/api-go/router/oauth_server.go @@ -1,41 +1,47 @@ 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" + "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" ) // 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 { return fmt.Errorf("configure OAuth server: %w", err) } - integration, err := service.ConfigureOAuthIntegration(model.DB, config) - if err != nil { 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 + config, err := service.OAuthServerConfigFromEnv() + if err != nil { + return fmt.Errorf("configure OAuth server: %w", err) + } + integration, err := service.ConfigureOAuthIntegration(model.DB, config) + if err != nil { + 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 } // 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") - browser := h.Guard(30, "browser") - tokens := h.Guard(60, "token") - resources := h.Guard(120, "resource") - activity := h.Guard(30, "activity") - router.GET("/.well-known/oauth-authorization-server", discovery, h.Metadata) - router.GET("/.well-known/oauth-protected-resource/api/oauth2", discovery, h.ResourceMetadata) - router.GET("/api/oauth2/authorize", browser, h.Authorize) - router.POST("/api/user/auth/oauth2/continue", browser, h.Continue) - router.POST("/api/user/auth/oauth2/consent", browser, h.Consent) - router.POST("/api/oauth2/token", tokens, h.Token) - router.POST("/api/oauth2/revoke", tokens, h.Revoke) - router.GET("/api/oauth2/catalog", resources, h.Catalog) - router.GET("/api/oauth2/balance", resources, h.Balance) - router.GET("/api/oauth2/usage/activity", activity, h.Activity) + h := controller.NewOAuthHTTP(integration) + discovery := h.Guard(120, "metadata") + browser := h.Guard(30, "browser") + tokens := h.Guard(60, "token") + resources := h.Guard(120, "resource") + activity := h.Guard(30, "activity") + router.GET("/.well-known/oauth-authorization-server", discovery, h.Metadata) + router.GET("/.well-known/oauth-protected-resource/api/oauth2", discovery, h.ResourceMetadata) + router.GET("/api/oauth2/authorize", browser, h.Authorize) + router.POST("/api/user/auth/oauth2/continue", browser, h.Continue) + router.POST("/api/user/auth/oauth2/consent", browser, h.Consent) + router.POST("/api/oauth2/token", tokens, h.Token) + router.POST("/api/oauth2/revoke", tokens, h.Revoke) + router.GET("/api/oauth2/catalog", resources, h.Catalog) + router.GET("/api/oauth2/balance", resources, h.Balance) + router.GET("/api/oauth2/usage/activity", activity, h.Activity) } diff --git a/apps/api-go/router/oidc_provider.go b/apps/api-go/router/oidc_provider.go index bc9fb9a8b..1485ae213 100644 --- a/apps/api-go/router/oidc_provider.go +++ b/apps/api-go/router/oidc_provider.go @@ -1,26 +1,35 @@ package router import ( - "net/http" - "github.com/LIghtJUNction/api.lmm.best/model" - "github.com/LIghtJUNction/api.lmm.best/service" - "github.com/gin-gonic/gin" + "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 +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 index 037275a8b..c041cd3b7 100644 --- a/apps/api-go/service/oidc_provider.go +++ b/apps/api-go/service/oidc_provider.go @@ -1,97 +1,203 @@ package service import ( - "context" - "crypto/rsa" - "crypto/x509" - "encoding/json" - "encoding/pem" - "errors" - "fmt" - "net" - "net/http" - "os" - "strconv" - "strings" - "time" + "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" + "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"` + 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 (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) 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) 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 +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) - }, - }) +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) + }, + }) } From d242ac5ce38c528352f76fe7b8fb38456d7780bb Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 19:55:36 +0800 Subject: [PATCH 07/10] chore(oidc): remove completed formatting bootstrap and rerun qualification --- .github/workflows/coweft-format.yml | 43 ----------------------------- 1 file changed, 43 deletions(-) delete mode 100644 .github/workflows/coweft-format.yml diff --git a/.github/workflows/coweft-format.yml b/.github/workflows/coweft-format.yml deleted file mode 100644 index 2a71d6464..000000000 --- a/.github/workflows/coweft-format.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Format CoWeft identity implementation -on: - push: - branches: ['feat/coweft-oidc'] -permissions: - contents: read -jobs: - format: - # One-time bootstrap housekeeping. Only a trusted same-repository branch; - # no pull-request event and no dependency scripts or application execution. - if: github.repository == 'TokenNotIncluded/api.lmm.best' - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - ref: feat/coweft-oidc - - uses: actions/setup-go@v5 - with: - go-version-file: apps/api-go/go.mod - cache: false - - name: Apply Go formatting only to the new identity implementation - shell: bash - run: | - set -euo pipefail - if [ "$(git rev-parse HEAD)" != "$GITHUB_SHA" ]; then - echo 'Branch advanced; do not alter another revision.' - exit 0 - fi - gofmt -w apps/api-go/oidcprovider/*.go \ - apps/api-go/service/oidc_provider.go \ - apps/api-go/router/oidc_provider.go \ - apps/api-go/router/oauth_server.go - git add -- 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 - if git diff --cached --quiet; then exit 0; fi - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git commit -m 'style(oidc): apply canonical Go formatting' - git push origin HEAD:refs/heads/feat/coweft-oidc From f073b6a12e8c338fd9e6f59c9c33ab4764c5764b Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 21:29:36 +0800 Subject: [PATCH 08/10] chore(coweft): sync redesigned forum frontend submodule --- apps/coweft | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/coweft b/apps/coweft index 648c58ca2..9a24c8946 160000 --- a/apps/coweft +++ b/apps/coweft @@ -1 +1 @@ -Subproject commit 648c58ca2ec59113f54fd2f66fc9ea060585dc9a +Subproject commit 9a24c894644df98de1c1d04dbeac163d28f810cc From a372940b98ce51d2f4c62a4e6fffc8f314316281 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 21:35:34 +0800 Subject: [PATCH 09/10] chore(coweft): advance frontend verification revision --- apps/coweft | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/coweft b/apps/coweft index 9a24c8946..18c48476c 160000 --- a/apps/coweft +++ b/apps/coweft @@ -1 +1 @@ -Subproject commit 9a24c894644df98de1c1d04dbeac163d28f810cc +Subproject commit 18c48476cef1eed869e1b32363576b2eed89514e From 4e8626807bab7fcc07250a96ca99880f3152fff2 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 23:52:22 +0800 Subject: [PATCH 10/10] fix(coweft): keep OIDC example out of the retired root deploy directory The repository contract test forbids a root deploy/ directory: deployment behavior lives in the Go and Rust backend CLIs, and the shell deploy/ tree was retired deliberately. Adding deploy/coweft/oidc.env.example recreated that directory, so TestRepositoryDeploymentBehaviorLivesInBackendCLIs failed on its first assertion in the Go default backend, Go full server qualification, and release artifact contract jobs. Move the example to packaging/common/lmm-api/lmm-oidc.env.example, matching the existing packaging convention, where component runtime examples ship next to their systemd units and packaging scripts. Update the single reference in docs/coweft-identity.md. No OIDC provider logic changes. Co-Authored-By: Claude Sonnet 5 --- docs/coweft-identity.md | 2 +- .../common/lmm-api/lmm-oidc.env.example | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename deploy/coweft/oidc.env.example => packaging/common/lmm-api/lmm-oidc.env.example (100%) diff --git a/docs/coweft-identity.md b/docs/coweft-identity.md index c2744be1e..d4ae40ebe 100644 --- a/docs/coweft-identity.md +++ b/docs/coweft-identity.md @@ -37,7 +37,7 @@ openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out /run/secrets/l 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 `deploy/coweft/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. +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. diff --git a/deploy/coweft/oidc.env.example b/packaging/common/lmm-api/lmm-oidc.env.example similarity index 100% rename from deploy/coweft/oidc.env.example rename to packaging/common/lmm-api/lmm-oidc.env.example