From 82449951707c1d09e7acdb824236aa2d9d1545a3 Mon Sep 17 00:00:00 2001 From: shenlan Date: Thu, 21 Aug 2025 15:28:14 +0800 Subject: [PATCH] Add OIDC utilities and tests --- light-idp/idp-server/internal/http/router.go | 6 +- .../idp-server/internal/http/router_test.go | 95 +++++++++++++++++++ light-idp/idp-server/internal/models/user.go | 5 +- .../idp-server/internal/oidc/authorize.go | 40 +++++++- .../idp-server/internal/oidc/discovery.go | 22 ++++- light-idp/idp-server/internal/oidc/jwks.go | 11 ++- light-idp/idp-server/internal/oidc/logout.go | 9 +- light-idp/idp-server/internal/oidc/oidc.go | 29 ++++++ light-idp/idp-server/internal/oidc/token.go | 64 ++++++++++++- .../idp-server/internal/oidc/userinfo.go | 23 ++++- .../idp-server/internal/store/interfaces.go | 2 + light-idp/idp-server/internal/store/memory.go | 20 ++++ 12 files changed, 309 insertions(+), 17 deletions(-) create mode 100644 light-idp/idp-server/internal/http/router_test.go create mode 100644 light-idp/idp-server/internal/oidc/oidc.go diff --git a/light-idp/idp-server/internal/http/router.go b/light-idp/idp-server/internal/http/router.go index be73559..fe9e706 100644 --- a/light-idp/idp-server/internal/http/router.go +++ b/light-idp/idp-server/internal/http/router.go @@ -5,14 +5,14 @@ import ( "light-idp/internal/middleware" "light-idp/internal/oidc" + "light-idp/internal/store" ) func NewRouter() *http.ServeMux { mux := http.NewServeMux() + oidc.SetStore(store.NewMemoryStore()) mux.HandleFunc("/authorize", oidc.HandleAuthorize) - mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { - oidc.HandleToken(w, r) - }) + mux.HandleFunc("/token", oidc.HandleToken) mux.HandleFunc("/userinfo", oidc.HandleUserInfo) mux.HandleFunc("/.well-known/openid-configuration", oidc.HandleDiscovery) mux.HandleFunc("/jwks", oidc.HandleJWKS) diff --git a/light-idp/idp-server/internal/http/router_test.go b/light-idp/idp-server/internal/http/router_test.go new file mode 100644 index 0000000..17c0455 --- /dev/null +++ b/light-idp/idp-server/internal/http/router_test.go @@ -0,0 +1,95 @@ +package httpserver + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "light-idp/internal/models" + "light-idp/internal/oidc" + "light-idp/internal/store" +) + +func TestOIDCFlow(t *testing.T) { + s := store.NewMemoryStore() + oidc.SetStore(s) + router := NewRouter() + // overwrite store set by router + oidc.SetStore(s) + + // Authorization request + req := httptest.NewRequest("GET", "/authorize?response_type=code&client_id=abc&redirect_uri=http://client/cb&scope=openid&state=xyz&user_id=123", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusFound { + t.Fatalf("authorize status = %d", rr.Code) + } + loc, err := url.Parse(rr.Header().Get("Location")) + if err != nil { + t.Fatalf("parse redirect: %v", err) + } + code := loc.Query().Get("code") + + // Token request + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("client_id", "abc") + req = httptest.NewRequest("POST", "/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("token status = %d", rr.Code) + } + var tokResp map[string]string + json.NewDecoder(rr.Body).Decode(&tokResp) + if tokResp["refresh_token"] == "" { + t.Fatalf("no refresh token returned") + } + u, err := s.Get("123") + if err != nil || u.RefreshToken != tokResp["refresh_token"] { + t.Fatalf("refresh token not stored") + } + + // Userinfo + ctx := context.WithValue(context.Background(), "user", models.User{ID: "123", Email: "a@example.com"}) + req = httptest.NewRequest("GET", "/userinfo", nil).WithContext(ctx) + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("userinfo status = %d", rr.Code) + } + + // Logout + req = httptest.NewRequest("GET", "/logout?user_id=123", nil) + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("logout status = %d", rr.Code) + } + u, _ = s.Get("123") + if u.RefreshToken != "" { + t.Fatalf("refresh token not revoked") + } + + // Discovery + req = httptest.NewRequest("GET", "/.well-known/openid-configuration", nil) + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("discovery status = %d", rr.Code) + } + + // JWKS + req = httptest.NewRequest("GET", "/jwks", nil) + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("jwks status = %d", rr.Code) + } +} diff --git a/light-idp/idp-server/internal/models/user.go b/light-idp/idp-server/internal/models/user.go index 81c1573..b8d181f 100644 --- a/light-idp/idp-server/internal/models/user.go +++ b/light-idp/idp-server/internal/models/user.go @@ -1,6 +1,7 @@ package models type User struct { - ID string - Email string + ID string + Email string + RefreshToken string } diff --git a/light-idp/idp-server/internal/oidc/authorize.go b/light-idp/idp-server/internal/oidc/authorize.go index 45ff138..393ac50 100644 --- a/light-idp/idp-server/internal/oidc/authorize.go +++ b/light-idp/idp-server/internal/oidc/authorize.go @@ -1,5 +1,41 @@ package oidc -import "net/http" +import ( + "net/http" + "net/url" -func HandleAuthorize(w http.ResponseWriter, r *http.Request) {} + "github.com/google/uuid" +) + +// HandleAuthorize parses OAuth2 authorization requests and issues a simple +// authorization code by redirecting back to the client. +func HandleAuthorize(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + clientID := r.Form.Get("client_id") + redirectURI := r.Form.Get("redirect_uri") + responseType := r.Form.Get("response_type") + state := r.Form.Get("state") + userID := r.Form.Get("user_id") + + if clientID == "" || redirectURI == "" || responseType != "code" { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + u, err := url.Parse(redirectURI) + if err != nil { + http.Error(w, "invalid redirect_uri", http.StatusBadRequest) + return + } + code := uuid.New().String() + authCodes[code] = userID + q := u.Query() + q.Set("code", code) + if state != "" { + q.Set("state", state) + } + u.RawQuery = q.Encode() + http.Redirect(w, r, u.String(), http.StatusFound) +} diff --git a/light-idp/idp-server/internal/oidc/discovery.go b/light-idp/idp-server/internal/oidc/discovery.go index b0cd6cf..b259c20 100644 --- a/light-idp/idp-server/internal/oidc/discovery.go +++ b/light-idp/idp-server/internal/oidc/discovery.go @@ -1,5 +1,23 @@ package oidc -import "net/http" +import ( + "encoding/json" + "net/http" +) -func HandleDiscovery(w http.ResponseWriter, r *http.Request) {} +// HandleDiscovery returns OpenID Connect discovery metadata. +func HandleDiscovery(w http.ResponseWriter, r *http.Request) { + issuer := "http://" + r.Host + meta := map[string]interface{}{ + "issuer": issuer, + "authorization_endpoint": issuer + "/authorize", + "token_endpoint": issuer + "/token", + "userinfo_endpoint": issuer + "/userinfo", + "jwks_uri": issuer + "/jwks", + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + "id_token_signing_alg_values_supported": []string{"RS256"}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(meta) +} diff --git a/light-idp/idp-server/internal/oidc/jwks.go b/light-idp/idp-server/internal/oidc/jwks.go index 74e477b..891908e 100644 --- a/light-idp/idp-server/internal/oidc/jwks.go +++ b/light-idp/idp-server/internal/oidc/jwks.go @@ -1,5 +1,12 @@ package oidc -import "net/http" +import ( + "encoding/json" + "net/http" +) -func HandleJWKS(w http.ResponseWriter, r *http.Request) {} +// HandleJWKS serves the JSON Web Key Set containing the public signing key. +func HandleJWKS(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"keys": []interface{}{jwk}}) +} diff --git a/light-idp/idp-server/internal/oidc/logout.go b/light-idp/idp-server/internal/oidc/logout.go index 18d811f..bdec49e 100644 --- a/light-idp/idp-server/internal/oidc/logout.go +++ b/light-idp/idp-server/internal/oidc/logout.go @@ -2,4 +2,11 @@ package oidc import "net/http" -func HandleLogout(w http.ResponseWriter, r *http.Request) {} +// HandleLogout revokes the user's refresh token and ends the session. +func HandleLogout(w http.ResponseWriter, r *http.Request) { + userID := r.URL.Query().Get("user_id") + if userStore != nil && userID != "" { + userStore.RevokeRefreshToken(userID) + } + w.WriteHeader(http.StatusOK) +} diff --git a/light-idp/idp-server/internal/oidc/oidc.go b/light-idp/idp-server/internal/oidc/oidc.go new file mode 100644 index 0000000..ef476e6 --- /dev/null +++ b/light-idp/idp-server/internal/oidc/oidc.go @@ -0,0 +1,29 @@ +package oidc + +import ( + "crypto/rand" + "crypto/rsa" + + jose "github.com/go-jose/go-jose/v3" + + "light-idp/internal/store" +) + +var ( + userStore store.UserStore + authCodes = make(map[string]string) + signingKey *rsa.PrivateKey + signer jose.Signer + jwk jose.JSONWebKey +) + +func init() { + signingKey, _ = rsa.GenerateKey(rand.Reader, 2048) + signer, _ = jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: signingKey}, nil) + jwk = jose.JSONWebKey{Key: &signingKey.PublicKey, Algorithm: string(jose.RS256), Use: "sig", KeyID: "1"} +} + +// SetStore configures the store used by handlers. +func SetStore(s store.UserStore) { + userStore = s +} diff --git a/light-idp/idp-server/internal/oidc/token.go b/light-idp/idp-server/internal/oidc/token.go index fb477c2..ee65689 100644 --- a/light-idp/idp-server/internal/oidc/token.go +++ b/light-idp/idp-server/internal/oidc/token.go @@ -1,11 +1,69 @@ package oidc import ( + "encoding/json" "net/http" + "strings" + "time" - oidc "github.com/coreos/go-oidc/v3/oidc" + oidclib "github.com/coreos/go-oidc/v3/oidc" + "github.com/go-jose/go-jose/v3/jwt" + "github.com/google/uuid" ) -func HandleToken(w http.ResponseWriter, r *http.Request) (*oidc.IDToken, error) { - return nil, nil +// HandleToken validates an authorization code and returns signed ID and access +// tokens. A refresh token is generated and persisted via the configured +// UserStore. +func HandleToken(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + if r.Form.Get("grant_type") != "authorization_code" { + http.Error(w, "unsupported grant type", http.StatusBadRequest) + return + } + code := r.Form.Get("code") + userID, ok := authCodes[code] + if !ok { + http.Error(w, "invalid code", http.StatusBadRequest) + return + } + delete(authCodes, code) + + clientID := r.Form.Get("client_id") + now := time.Now() + claims := jwt.Claims{ + Issuer: "http://localhost", + Subject: userID, + Audience: jwt.Audience{clientID}, + Expiry: jwt.NewNumericDate(now.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(now), + } + rawIDToken, err := jwt.Signed(signer).Claims(claims).CompactSerialize() + if err != nil { + http.Error(w, "failed to sign", http.StatusInternalServerError) + return + } + accessToken := uuid.New().String() + refreshToken := uuid.New().String() + if userStore != nil { + userStore.SaveRefreshToken(userID, refreshToken) + } + + // ensure scope contains openid + scope := r.Form.Get("scope") + if !strings.Contains(scope, oidclib.ScopeOpenID) { + scope = scope + " " + oidclib.ScopeOpenID + } + + resp := map[string]string{ + "access_token": accessToken, + "id_token": rawIDToken, + "refresh_token": refreshToken, + "token_type": "Bearer", + "scope": scope, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) } diff --git a/light-idp/idp-server/internal/oidc/userinfo.go b/light-idp/idp-server/internal/oidc/userinfo.go index 0485b43..fd652d3 100644 --- a/light-idp/idp-server/internal/oidc/userinfo.go +++ b/light-idp/idp-server/internal/oidc/userinfo.go @@ -1,5 +1,24 @@ package oidc -import "net/http" +import ( + "encoding/json" + "net/http" -func HandleUserInfo(w http.ResponseWriter, r *http.Request) {} + "light-idp/internal/models" +) + +// HandleUserInfo returns user claims from the authenticated session. +func HandleUserInfo(w http.ResponseWriter, r *http.Request) { + v := r.Context().Value("user") + user, ok := v.(models.User) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + claims := map[string]string{ + "sub": user.ID, + "email": user.Email, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(claims) +} diff --git a/light-idp/idp-server/internal/store/interfaces.go b/light-idp/idp-server/internal/store/interfaces.go index 2c02fec..b3066c1 100644 --- a/light-idp/idp-server/internal/store/interfaces.go +++ b/light-idp/idp-server/internal/store/interfaces.go @@ -11,4 +11,6 @@ var ErrNotFound = errors.New("not found") type UserStore interface { Create(models.User) error Get(id string) (models.User, error) + SaveRefreshToken(id, token string) error + RevokeRefreshToken(id string) error } diff --git a/light-idp/idp-server/internal/store/memory.go b/light-idp/idp-server/internal/store/memory.go index f7c3543..4ae5c4b 100644 --- a/light-idp/idp-server/internal/store/memory.go +++ b/light-idp/idp-server/internal/store/memory.go @@ -22,3 +22,23 @@ func (m *MemoryStore) Get(id string) (models.User, error) { } return u, nil } + +func (m *MemoryStore) SaveRefreshToken(id, token string) error { + u, ok := m.users[id] + if !ok { + u = models.User{ID: id} + } + u.RefreshToken = token + m.users[id] = u + return nil +} + +func (m *MemoryStore) RevokeRefreshToken(id string) error { + u, ok := m.users[id] + if !ok { + return ErrNotFound + } + u.RefreshToken = "" + m.users[id] = u + return nil +}