package api import ( "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "html/template" "net/http" "net/url" "os" "strings" "time" "github.com/mish/dealspace/lib" ) // OAuthHandlers holds dependencies for OAuth endpoints. type OAuthHandlers struct { DB *lib.DB Cfg *lib.Config } // NewOAuthHandlers creates OAuth handlers. func NewOAuthHandlers(db *lib.DB, cfg *lib.Config) *OAuthHandlers { return &OAuthHandlers{DB: db, Cfg: cfg} } func baseURL(r *http.Request) string { scheme := "https" if r.TLS == nil && !strings.HasPrefix(r.Header.Get("X-Forwarded-Proto"), "https") { scheme = "http" } return scheme + "://" + r.Host } // Metadata serves GET /.well-known/oauth-authorization-server (RFC 8414). func (o *OAuthHandlers) Metadata(w http.ResponseWriter, r *http.Request) { base := baseURL(r) JSONResponse(w, http.StatusOK, map[string]any{ "issuer": base, "authorization_endpoint": base + "/oauth/authorize", "token_endpoint": base + "/oauth/token", "revocation_endpoint": base + "/oauth/revoke", "response_types_supported": []string{"code"}, "grant_types_supported": []string{"authorization_code"}, "code_challenge_methods_supported": []string{"S256"}, "token_endpoint_auth_methods_supported": []string{"none"}, }) } // ResourceMetadata serves GET /.well-known/oauth-protected-resource (RFC 9728). func (o *OAuthHandlers) ResourceMetadata(w http.ResponseWriter, r *http.Request) { base := baseURL(r) JSONResponse(w, http.StatusOK, map[string]any{ "resource": base, "authorization_servers": []string{base}, }) } // Authorize handles GET /oauth/authorize — shows consent page or redirects to login. func (o *OAuthHandlers) Authorize(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() clientID := q.Get("client_id") redirectURI := q.Get("redirect_uri") responseType := q.Get("response_type") codeChallenge := q.Get("code_challenge") codeChallengeMethod := q.Get("code_challenge_method") state := q.Get("state") _ = q.Get("scope") // scope passed through via query string to POST handler // Validate required params if responseType != "code" { oauthError(w, redirectURI, state, "unsupported_response_type", "Only response_type=code is supported") return } if codeChallenge == "" || codeChallengeMethod != "S256" { oauthError(w, redirectURI, state, "invalid_request", "PKCE with S256 is required") return } // Validate client client, err := lib.OAuthClientByID(o.DB, clientID) if err != nil || client == nil { oauthError(w, redirectURI, state, "invalid_client", "Unknown client_id") return } // Validate redirect_uri if !validRedirectURI(client.RedirectURIs, redirectURI) { ErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid redirect_uri") return } // Check if user is authenticated via JWT in cookie or Authorization header userID := o.extractUserID(r) if userID == "" { // Redirect to login with return URL loginURL := "/app/login?next=" + url.QueryEscape(r.URL.RequestURI()) http.Redirect(w, r, loginURL, http.StatusFound) return } // Show consent page o.serveConsentPage(w, client.ClientName, r.URL.RequestURI()) } // AuthorizeApprove handles POST /oauth/authorize — processes consent approval. func (o *OAuthHandlers) AuthorizeApprove(w http.ResponseWriter, r *http.Request) { // Parse the original authorize params from the form if err := r.ParseForm(); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid form data") return } // The form includes the original query string in a hidden field originalQuery := r.FormValue("original_query") parsedQuery, err := url.ParseQuery(originalQuery) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid query parameters") return } clientID := parsedQuery.Get("client_id") redirectURI := parsedQuery.Get("redirect_uri") codeChallenge := parsedQuery.Get("code_challenge") state := parsedQuery.Get("state") scope := parsedQuery.Get("scope") // Check denial if r.FormValue("action") == "deny" { oauthRedirect(w, r, redirectURI, state, "", "access_denied") return } // Validate client again client, err := lib.OAuthClientByID(o.DB, clientID) if err != nil || client == nil { ErrorResponse(w, http.StatusBadRequest, "invalid_client", "Unknown client_id") return } if !validRedirectURI(client.RedirectURIs, redirectURI) { ErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid redirect_uri") return } // User must be authenticated userID := o.extractUserID(r) if userID == "" { ErrorResponse(w, http.StatusUnauthorized, "login_required", "Authentication required") return } // Generate authorization code codeBytes := make([]byte, 32) if _, err := rand.Read(codeBytes); err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to generate code") return } codeStr := hex.EncodeToString(codeBytes) oauthCode := &lib.OAuthCode{ Code: codeStr, ClientID: clientID, UserID: userID, RedirectURI: redirectURI, CodeChallenge: codeChallenge, Scope: scope, ExpiresAt: time.Now().Add(10 * time.Minute).UnixMilli(), Used: false, } if err := lib.OAuthCodeCreate(o.DB, oauthCode); err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to store authorization code") return } oauthRedirect(w, r, redirectURI, state, codeStr, "") } // Token handles POST /oauth/token — exchanges authorization code for access token. func (o *OAuthHandlers) Token(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { tokenError(w, "invalid_request", "Invalid form data") return } grantType := r.FormValue("grant_type") if grantType != "authorization_code" { tokenError(w, "unsupported_grant_type", "Only authorization_code is supported") return } codeStr := r.FormValue("code") redirectURI := r.FormValue("redirect_uri") clientID := r.FormValue("client_id") codeVerifier := r.FormValue("code_verifier") if codeStr == "" || clientID == "" || codeVerifier == "" { tokenError(w, "invalid_request", "Missing required parameters") return } // Consume the code (marks used, checks expiry) code, err := lib.OAuthCodeConsume(o.DB, codeStr) if err != nil { tokenError(w, "invalid_grant", "Invalid or expired authorization code") return } // Verify client_id matches if code.ClientID != clientID { tokenError(w, "invalid_grant", "Client mismatch") return } // Verify redirect_uri matches if code.RedirectURI != redirectURI { tokenError(w, "invalid_grant", "Redirect URI mismatch") return } // Verify PKCE: SHA256(code_verifier) must match code_challenge verifierHash := sha256.Sum256([]byte(codeVerifier)) computedChallenge := base64.RawURLEncoding.EncodeToString(verifierHash[:]) if computedChallenge != code.CodeChallenge { tokenError(w, "invalid_grant", "PKCE verification failed") return } // Generate access token tokenBytes := make([]byte, 64) if _, err := rand.Read(tokenBytes); err != nil { tokenError(w, "server_error", "Failed to generate token") return } tokenStr := hex.EncodeToString(tokenBytes) now := time.Now() expiresIn := int64(24 * 60 * 60) // 24 hours in seconds oauthToken := &lib.OAuthToken{ Token: tokenStr, ClientID: clientID, UserID: code.UserID, Scope: code.Scope, ExpiresAt: now.Add(24 * time.Hour).UnixMilli(), Revoked: false, CreatedAt: now.UnixMilli(), } if err := lib.OAuthTokenCreate(o.DB, oauthToken); err != nil { tokenError(w, "server_error", "Failed to store token") return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") json.NewEncoder(w).Encode(map[string]any{ "access_token": tokenStr, "token_type": "Bearer", "expires_in": expiresIn, }) } // Revoke handles POST /oauth/revoke — revokes an access token (RFC 7009). func (o *OAuthHandlers) Revoke(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusOK) return } tokenStr := r.FormValue("token") if tokenStr != "" { _ = lib.OAuthTokenRevoke(o.DB, tokenStr) } // Always return 200 per RFC 7009 w.WriteHeader(http.StatusOK) } // extractUserID checks JWT from Authorization header or ds_token cookie. func (o *OAuthHandlers) extractUserID(r *http.Request) string { // Try Authorization header first auth := r.Header.Get("Authorization") if strings.HasPrefix(auth, "Bearer ") { token := strings.TrimPrefix(auth, "Bearer ") claims, err := validateJWT(token, o.Cfg.JWTSecret) if err == nil { session, err := lib.SessionByID(o.DB, claims.SessionID) if err == nil && session != nil && !session.Revoked && session.ExpiresAt >= time.Now().UnixMilli() { return claims.UserID } } } // Try cookie cookie, err := r.Cookie("ds_token") if err == nil && cookie.Value != "" { claims, err := validateJWT(cookie.Value, o.Cfg.JWTSecret) if err == nil { session, err := lib.SessionByID(o.DB, claims.SessionID) if err == nil && session != nil && !session.Revoked && session.ExpiresAt >= time.Now().UnixMilli() { return claims.UserID } } } return "" } func (o *OAuthHandlers) serveConsentPage(w http.ResponseWriter, appName, authorizeURI string) { // Extract query string from the authorize URI parsed, _ := url.Parse(authorizeURI) queryString := parsed.RawQuery layoutCandidates := []string{ "portal/templates/layouts/auth.html", "/opt/dealspace/portal/templates/layouts/auth.html", } pageCandidates := []string{ "portal/templates/auth/consent.html", "/opt/dealspace/portal/templates/auth/consent.html", } var layoutPath, pagePath string for _, p := range layoutCandidates { if _, err := os.Stat(p); err == nil { layoutPath = p break } } for _, p := range pageCandidates { if _, err := os.Stat(p); err == nil { pagePath = p break } } if layoutPath == "" || pagePath == "" { http.Error(w, "Consent template not found", http.StatusInternalServerError) return } tmpl, err := template.ParseFiles(layoutPath, pagePath) if err != nil { http.Error(w, "Template parse error: "+err.Error(), http.StatusInternalServerError) return } data := struct { Title string AppName string OriginalQuery string }{ Title: "Authorize Application — Dealspace", AppName: appName, OriginalQuery: queryString, } w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.ExecuteTemplate(w, "layout", data) } func validRedirectURI(registered []string, uri string) bool { parsed, err := url.Parse(uri) if err != nil { return false } for _, r := range registered { rParsed, err := url.Parse(r) if err != nil { continue } // Match scheme + host (port-agnostic for localhost) if parsed.Scheme == rParsed.Scheme && parsed.Hostname() == rParsed.Hostname() { return true } } return false } func oauthError(w http.ResponseWriter, redirectURI, state, errCode, errDesc string) { if redirectURI == "" { ErrorResponse(w, http.StatusBadRequest, errCode, errDesc) return } oauthRedirect(w, nil, redirectURI, state, "", errCode) } func oauthRedirect(w http.ResponseWriter, r *http.Request, redirectURI, state, code, errCode string) { u, err := url.Parse(redirectURI) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid redirect_uri") return } q := u.Query() if code != "" { q.Set("code", code) } if errCode != "" { q.Set("error", errCode) } if state != "" { q.Set("state", state) } u.RawQuery = q.Encode() if r != nil { http.Redirect(w, r, u.String(), http.StatusFound) } else { w.Header().Set("Location", u.String()) w.WriteHeader(http.StatusFound) } } func tokenError(w http.ResponseWriter, errCode, errDesc string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "error": errCode, "error_description": errDesc, }) } // SeedOAuthClient creates the default Claude OAuth client if it doesn't exist. func SeedOAuthClient(db *lib.DB) { client := &lib.OAuthClient{ ClientID: "claude", ClientName: "Claude", RedirectURIs: []string{ "http://localhost", "http://127.0.0.1", "http://localhost:0", "http://127.0.0.1:0", }, CreatedAt: time.Now().UnixMilli(), } _ = lib.OAuthClientCreate(db, client) }