Merge pull request #199 from svc-design/codex/implement-oauth2-authorization-flow

Implement basic OIDC flows and tests
This commit is contained in:
shenlan 2025-08-21 15:36:16 +08:00 committed by GitHub
commit 22d083cb24
12 changed files with 309 additions and 17 deletions

View File

@ -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)

View File

@ -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)
}
}

View File

@ -1,6 +1,7 @@
package models
type User struct {
ID string
Email string
ID string
Email string
RefreshToken string
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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}})
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}