9992fb4ef1
Refactor the application to a Go-based architecture for improved performance and maintainability. Replace the Dockerfile to utilize a multi-stage build process, enhancing image efficiency. Implement comprehensive session store tests to ensure reliability and create new OAuth handlers for managing authentication efficiently. Update documentation to reflect these structural changes.
338 lines
9.2 KiB
Go
338 lines
9.2 KiB
Go
package handlers
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"gitlab.com/unboundsoftware/auth0mock/auth"
|
|
"gitlab.com/unboundsoftware/auth0mock/store"
|
|
)
|
|
|
|
//go:embed templates/login.html
|
|
var templateFS embed.FS
|
|
|
|
// OAuthHandler handles OAuth/OIDC endpoints
|
|
type OAuthHandler struct {
|
|
jwtService *auth.JWTService
|
|
sessionStore *store.SessionStore
|
|
loginTemplate *template.Template
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewOAuthHandler creates a new OAuth handler
|
|
func NewOAuthHandler(jwtService *auth.JWTService, sessionStore *store.SessionStore, logger *slog.Logger) (*OAuthHandler, error) {
|
|
tmpl, err := template.ParseFS(templateFS, "templates/login.html")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse login template: %w", err)
|
|
}
|
|
|
|
return &OAuthHandler{
|
|
jwtService: jwtService,
|
|
sessionStore: sessionStore,
|
|
loginTemplate: tmpl,
|
|
logger: logger,
|
|
}, nil
|
|
}
|
|
|
|
// TokenRequest represents the token endpoint request body
|
|
type TokenRequest struct {
|
|
GrantType string `json:"grant_type"`
|
|
ClientID string `json:"client_id"`
|
|
ClientSecret string `json:"client_secret"`
|
|
Code string `json:"code"`
|
|
CodeVerifier string `json:"code_verifier"`
|
|
RedirectURI string `json:"redirect_uri"`
|
|
}
|
|
|
|
// TokenResponse represents the token endpoint response
|
|
type TokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
IDToken string `json:"id_token"`
|
|
Scope string `json:"scope"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
TokenType string `json:"token_type"`
|
|
}
|
|
|
|
// Token handles the POST /oauth/token endpoint
|
|
func (h *OAuthHandler) Token(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
grantType := r.FormValue("grant_type")
|
|
clientID := r.FormValue("client_id")
|
|
code := r.FormValue("code")
|
|
codeVerifier := r.FormValue("code_verifier")
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
switch grantType {
|
|
case "client_credentials":
|
|
h.handleClientCredentials(w, clientID)
|
|
case "authorization_code", "":
|
|
if code != "" {
|
|
h.handleAuthorizationCode(w, code, codeVerifier, clientID)
|
|
} else {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "Missing code"})
|
|
}
|
|
default:
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "Unsupported grant type"})
|
|
}
|
|
}
|
|
|
|
func (h *OAuthHandler) handleClientCredentials(w http.ResponseWriter, clientID string) {
|
|
if clientID == "" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "Missing client_id"})
|
|
return
|
|
}
|
|
|
|
adminClaim := map[string]interface{}{
|
|
h.jwtService.AdminClaim(): true,
|
|
}
|
|
|
|
accessToken, err := h.jwtService.SignAccessToken(
|
|
"auth0|management",
|
|
clientID,
|
|
"management@example.org",
|
|
[]map[string]interface{}{adminClaim},
|
|
)
|
|
if err != nil {
|
|
h.logger.Error("failed to sign access token", "error", err)
|
|
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
idToken, err := h.jwtService.SignIDToken(
|
|
"auth0|management",
|
|
clientID,
|
|
"",
|
|
"management@example.org",
|
|
"Management API",
|
|
"Management",
|
|
"API",
|
|
"",
|
|
[]map[string]interface{}{adminClaim},
|
|
)
|
|
if err != nil {
|
|
h.logger.Error("failed to sign ID token", "error", err)
|
|
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.logger.Info("signed token for management API")
|
|
|
|
json.NewEncoder(w).Encode(TokenResponse{
|
|
AccessToken: accessToken,
|
|
IDToken: idToken,
|
|
Scope: "openid%20profile%20email",
|
|
ExpiresIn: 7200,
|
|
TokenType: "Bearer",
|
|
})
|
|
}
|
|
|
|
func (h *OAuthHandler) handleAuthorizationCode(w http.ResponseWriter, code, codeVerifier, clientID string) {
|
|
session, ok := h.sessionStore.Get(code)
|
|
if !ok {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid code"})
|
|
return
|
|
}
|
|
|
|
// Verify PKCE if code_verifier is provided
|
|
if codeVerifier != "" && session.CodeChallenge != "" {
|
|
// Determine method - default to S256 if challenge looks like a hash
|
|
method := auth.PKCEMethodS256
|
|
if len(session.CodeChallenge) < 43 {
|
|
method = auth.PKCEMethodPlain
|
|
}
|
|
|
|
if !auth.VerifyPKCE(codeVerifier, session.CodeChallenge, method) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid code_verifier"})
|
|
return
|
|
}
|
|
}
|
|
|
|
accessToken, err := h.jwtService.SignAccessToken(
|
|
"auth0|"+session.Email,
|
|
session.ClientID,
|
|
session.Email,
|
|
session.CustomClaims,
|
|
)
|
|
if err != nil {
|
|
h.logger.Error("failed to sign access token", "error", err)
|
|
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
idToken, err := h.jwtService.SignIDToken(
|
|
"auth0|"+session.Email,
|
|
session.ClientID,
|
|
session.Nonce,
|
|
session.Email,
|
|
"Example Person",
|
|
"Example",
|
|
"Person",
|
|
"https://cdn.playbuzz.com/cdn/5458360f-32ea-460e-a707-1a2d26760558/70bda687-cb84-4756-8a44-8cf735ed87b3.jpg",
|
|
session.CustomClaims,
|
|
)
|
|
if err != nil {
|
|
h.logger.Error("failed to sign ID token", "error", err)
|
|
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.logger.Info("signed token", "email", session.Email)
|
|
|
|
// Clean up session after successful token exchange
|
|
h.sessionStore.Delete(code)
|
|
|
|
json.NewEncoder(w).Encode(TokenResponse{
|
|
AccessToken: accessToken,
|
|
IDToken: idToken,
|
|
Scope: "openid%20profile%20email",
|
|
ExpiresIn: 7200,
|
|
TokenType: "Bearer",
|
|
})
|
|
}
|
|
|
|
// Code handles the POST /code endpoint (form submission from login page)
|
|
func (h *OAuthHandler) Code(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
email := r.FormValue("email")
|
|
password := r.FormValue("password")
|
|
codeChallenge := r.FormValue("codeChallenge")
|
|
redirect := r.FormValue("redirect")
|
|
state := r.FormValue("state")
|
|
nonce := r.FormValue("nonce")
|
|
clientID := r.FormValue("clientId")
|
|
admin := r.FormValue("admin") == "true"
|
|
|
|
if email == "" || password == "" || codeChallenge == "" {
|
|
h.logger.Debug("invalid code request", "email", email, "hasPassword", password != "", "hasChallenge", codeChallenge != "")
|
|
http.Error(w, "Email, password, or code challenge is missing", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
adminClaim := map[string]interface{}{
|
|
h.jwtService.AdminClaim(): admin,
|
|
}
|
|
|
|
session := &store.Session{
|
|
Email: email,
|
|
Password: password,
|
|
State: state,
|
|
Nonce: nonce,
|
|
ClientID: clientID,
|
|
CodeChallenge: codeChallenge,
|
|
CustomClaims: []map[string]interface{}{adminClaim},
|
|
}
|
|
|
|
h.sessionStore.Create(codeChallenge, session)
|
|
|
|
redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirect, codeChallenge, url.QueryEscape(state))
|
|
http.Redirect(w, r, redirectURL, http.StatusFound)
|
|
}
|
|
|
|
// Authorize handles the GET /authorize endpoint
|
|
func (h *OAuthHandler) Authorize(w http.ResponseWriter, r *http.Request) {
|
|
redirect := r.URL.Query().Get("redirect_uri")
|
|
state := r.URL.Query().Get("state")
|
|
nonce := r.URL.Query().Get("nonce")
|
|
clientID := r.URL.Query().Get("client_id")
|
|
codeChallenge := r.URL.Query().Get("code_challenge")
|
|
prompt := r.URL.Query().Get("prompt")
|
|
responseMode := r.URL.Query().Get("response_mode")
|
|
|
|
// Try to get existing session from cookie
|
|
cookie, err := r.Cookie("auth0")
|
|
var existingCode string
|
|
if err == nil {
|
|
existingCode = cookie.Value
|
|
}
|
|
|
|
// Handle response_mode=query with existing session
|
|
if responseMode == "query" && existingCode != "" {
|
|
if h.sessionStore.Update(existingCode, codeChallenge, func(s *store.Session) {
|
|
s.Nonce = nonce
|
|
s.State = state
|
|
s.CodeChallenge = codeChallenge
|
|
}) {
|
|
redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirect, codeChallenge, state)
|
|
http.Redirect(w, r, redirectURL, http.StatusFound)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Handle prompt=none with response_mode=web_message (silent auth)
|
|
if prompt == "none" && responseMode == "web_message" && existingCode != "" {
|
|
session, ok := h.sessionStore.Get(existingCode)
|
|
if ok {
|
|
h.sessionStore.Update(existingCode, existingCode, func(s *store.Session) {
|
|
s.Nonce = nonce
|
|
s.State = state
|
|
s.CodeChallenge = codeChallenge
|
|
})
|
|
|
|
// Send postMessage response
|
|
w.Header().Set("Content-Type", "text/html")
|
|
fmt.Fprintf(w, `<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<script type="text/javascript">
|
|
(() => {
|
|
const msg = {
|
|
type: 'authorization_response',
|
|
response: {
|
|
code: '%s',
|
|
state: '%s'
|
|
}
|
|
}
|
|
parent.postMessage(msg, "*")
|
|
})()
|
|
</script>
|
|
</body>
|
|
</html>`, existingCode, session.State)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Set cookie for session tracking
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "auth0",
|
|
Value: codeChallenge,
|
|
Path: "/",
|
|
SameSite: http.SameSiteNoneMode,
|
|
Secure: true,
|
|
HttpOnly: true,
|
|
})
|
|
|
|
// Render login form
|
|
w.Header().Set("Content-Type", "text/html")
|
|
data := map[string]string{
|
|
"Redirect": redirect,
|
|
"State": state,
|
|
"Nonce": nonce,
|
|
"ClientID": clientID,
|
|
"CodeChallenge": codeChallenge,
|
|
}
|
|
|
|
if err := h.loginTemplate.Execute(w, data); err != nil {
|
|
h.logger.Error("failed to render login template", "error", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|