Merge pull request #199 from svc-design/codex/implement-oauth2-authorization-flow
Implement basic OIDC flows and tests
This commit is contained in:
commit
22d083cb24
@ -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)
|
||||
|
||||
95
light-idp/idp-server/internal/http/router_test.go
Normal file
95
light-idp/idp-server/internal/http/router_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package models
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
ID string
|
||||
Email string
|
||||
RefreshToken string
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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}})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
29
light-idp/idp-server/internal/oidc/oidc.go
Normal file
29
light-idp/idp-server/internal/oidc/oidc.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user