Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions fernet.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package main

import (
"encoding/json"
"fmt"
"time"

"github.com/fernet/fernet-go"
)

func decryptFernetToken(accessToken, fernetKey string, maxAge int) (*UserInfo, error) {
// Parse the Fernet key
key, err := fernet.DecodeKey(fernetKey)
if err != nil {
return nil, fmt.Errorf("invalid fernet key: %w", err)
}

// Decrypt the token
decrypted := fernet.VerifyAndDecrypt([]byte(accessToken), time.Duration(maxAge)*time.Second, []*fernet.Key{key})
if decrypted == nil {
return nil, fmt.Errorf("failed to decrypt access_token - invalid token or key")
}

fmt.Printf("[DEBUG] \n\nDecrypted token: %s\n\n", string(decrypted))

// The decrypted payload is a JSON array with a single object
var payload []UserInfo
err = json.Unmarshal(decrypted, &payload)
if err != nil {
return nil, fmt.Errorf("failed to parse decrypted JSON: %w (raw data: %s)", err, string(decrypted))
}

if len(payload) == 0 {
return nil, fmt.Errorf("empty payload in decrypted token")
}

return &payload[0], nil
}
57 changes: 57 additions & 0 deletions fernet_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package main

import (
"encoding/json"
"fmt"
"os"
"testing"

"github.com/fernet/fernet-go"
)

func TestFernet(t *testing.T) {
// --- TEST SETUP ---
// In a real scenario, this key would be provided by Login.gov or your configuration.
// It must be a URL-safe Base64 encoded 32-byte key.
// Read the key from environment variable
encodedKey := os.Getenv("FERNET_KEY")
if encodedKey == "" {
panic("FERNET_KEY environment variable not set")
}
//[{"email": "[email protected]", "given_name": "", "family_name": "", "return_url": "https://apps-dev.gsd.esrl.noaa.gov/vx/formsui/", "return_html": "https://apps-dev.gsd.esrl.noaa.gov/vx/formsui/"}]
payload_mdev := []byte(`[{"Email": "[email protected]", "GivenName": "", "FamilyName": "", "ReturnURL": "https://apps-dev.gsd.esrl.noaa.gov/vx/formsui/", "ReturnHTML": "https://apps-dev.gsd.esrl.noaa.gov/vx/formsui/"}]`)
payload_dsg := []byte(`[{"Email": "[email protected]", "GivenName": "", "FamilyName": "", "ReturnURL": "https://apps-dev.gsd.esrl.noaa.gov/vx/formsui/", "ReturnHTML": "https://apps-dev.gsd.esrl.noaa.gov/vx/formsui/"}]`)
payload_vx := []byte(`[{"Email": "[email protected]", "GivenName": "", "FamilyName": "", "ReturnURL": "https://apps-dev.gsd.esrl.noaa.gov/vx/formsui/", "ReturnHTML": "https://apps-dev.gsd.esrl.noaa.gov/vx/formsui/"}]`)
key, err := fernet.DecodeKey(encodedKey)
if err != nil {
panic(fmt.Sprintf("Failed to decode FERNET_KEY: %v", err))
}
fmt.Printf("Test Key: %s\n", encodedKey)
// Create a dummy "Login.gov" style token containing JSON data
for _, payload := range [][]byte{payload_mdev, payload_dsg, payload_vx} {
token, err := fernet.EncryptAndSign(payload, key)
if err != nil {
panic(err)
}
tokenString := string(token)
fmt.Printf("\n\nEncrypted Token: for payload %s is \n\n%s\n\n", payload, tokenString)

// --- DECODING EXAMPLE ---

// 1. Define TTL (Time To Live)
// Login.gov tokens may have strict expiration.
// Use 0 if you want to bypass expiration checks for debugging.
maxAge := 0

// 2. Decode
decryptedData, err := decryptFernetToken(tokenString, encodedKey, maxAge)
if err != nil {
fmt.Printf("Error: %v\n", err)
fmt.Printf("Failed to decrypt token for %s\n", payload)
t.Fail()
} else {
jsonData, _ := json.Marshal(decryptedData)
fmt.Printf("\n\n✅ Success! Decoded Data:\n%s\n\n", string(jsonData))
}
}
}
36 changes: 36 additions & 0 deletions forms.go
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,42 @@ func handleNamedFunction(vStr string, fields map[string]interface{}, key string)
return selectMode
}

func GetRole(accessToken, fernetKey string) (UserRoleInfo, error) {
cluster := GetConnection(GetCBCredentials())
credentials := GetCBCredentials()
bucket := cluster.Bucket(credentials.CBBucket)
collection := bucket.Collection("COMMON")
getResult, err := collection.Get("MD:V01:roles:vxFormsUI", &gocb.GetOptions{})
if err != nil {
return UserRoleInfo{}, err
}
var doc map[string]interface{}
if err := getResult.Content(&doc); err != nil {
return UserRoleInfo{}, err
}
rolesData, ok := doc["roles"].(map[string]interface{})
if !ok || rolesData == nil {
return UserRoleInfo{}, errors.New("roles document does not contain a roles map")
}
userInfo, err := decryptFernetToken(accessToken, fernetKey, 0)
if err != nil {
fmt.Printf("Error decrypting access token: %v You must authenticate with login.gov. \n", err)
return UserRoleInfo{}, errors.New("user role not found")
}
for userRolesKey, userRolesValue := range rolesData {
userRoles, ok := userRolesValue.([]interface{})
if !ok {
continue
}
for _, roleEmail := range userRoles {
if roleEmailStr, ok := roleEmail.(string); ok && strings.EqualFold(roleEmailStr, userInfo.Email) {
return UserRoleInfo{UserRole: userRolesKey, UserInfo: *userInfo}, nil
}
}
}
return UserRoleInfo{}, errors.New("user role not found for user with email: " + userInfo.Email)
}

// GetJobSpecIDs retrieves all job specification IDs from the Couchbase database.
// It connects to the database using credentials obtained from GetCBCredentials,
// executes a N1QL query to select document IDs from the 'vxdata._default.COMMON' bucket
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.24.2

require (
github.com/couchbase/gocb/v2 v2.11.2
github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611
github.com/gin-gonic/gin v1.11.0
gopkg.in/yaml.v3 v3.0.1
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ github.com/couchbaselabs/gocbconnstr/v2 v2.0.0/go.mod h1:o7T431UOfFVHDNvMBUmUxpH
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611 h1:JwYtKJ/DVEoIA5dH45OEU7uoryZY/gjd/BQiwwAOImM=
github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611/go.mod h1:zHMNeYgqrTpKyjawjitDg0Osd1P/FmeA0SZLYK3RfLQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
Expand Down
21 changes: 3 additions & 18 deletions jobSet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"os"
"strings"
"testing"
)

Expand Down Expand Up @@ -64,26 +63,12 @@ func TestCreateJobSet(t *testing.T) {
t.Fatalf("CreateJobSetFromName returned error: %v", err)
}

var jobSetDoc []map[string]interface{}
var jobSetDoc map[string]interface{}
err = json.Unmarshal(jsonDoc, &jobSetDoc)
if err != nil {
t.Fatalf("Failed to unmarshal JSON: %v", err)
}
if len(jobSetDoc) == 0 || jobSetDoc[0]["id"] == nil {
t.Error("Expected jobSet.id to be non-nil")
}
if len(jobSetDoc) == 0 || strings.Contains(jobSetDoc[0]["id"].(string), jobID) {
t.Errorf("Expected document id to contain %q", jobID)
}
if len(jobSetDoc) != 31 {
t.Errorf("Expected 31 documents, got %d", len(jobSetDoc))
}
for _, doc := range jobSetDoc {
for key, value := range doc {
if strValue, ok := value.(string); ok && strings.Contains(strValue, ":RRFSv2:") {
t.Errorf("Expected field %q to not contain the base model identifier ':RRFSv2:', but got: %q", key, strValue)
break
}
}
if len(jobSetDoc) != 3 {
t.Errorf("Expected jobSet to have 3 elements, got %d", len(jobSetDoc))
}
}
32 changes: 30 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ import (
"github.com/gin-gonic/gin"
)

type UserInfo struct {
Email string `json:"email"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
ReturnURL string `json:"return_url"`
ReturnHTML string `json:"return_html"`
}

type UserRoleInfo struct {
UserRole string `json:"user_role"`
UserInfo UserInfo `json:"user_info"`
}

// main is the entry point for the vxFormsUI web server.

// It configures the Gin router, registers template functions, serves static assets,
Expand Down Expand Up @@ -95,6 +108,19 @@ func main() {
api := r.Group(root)

api.GET("/", func(c *gin.Context) {
accessToken := c.Query("access_token")
fernetKey := os.Getenv("FERNET_KEY")
userRoleInfo := UserRoleInfo{}
if accessToken != "" && fernetKey != "" {
var err error
userRoleInfo, err = GetRole(accessToken, fernetKey)
if err != nil {
fmt.Printf("Error getting user role: %v\n", err)
// Continue with empty userRoleInfo, which will indicate no role/user info in the template
}
} else {
fmt.Println("No access token or fernet key provided. User will not be authenticated.")
}
templates, err := GetFormTemplates()
if err != nil {
c.String(http.StatusInternalServerError, "Error loading forms")
Expand All @@ -112,12 +138,13 @@ func main() {
"BugsLink": "https://github.com/NOAA-GSL/vxFormsUI/issues",
"BugsText": "Bugs/Issues (GitHub)",
"EmailText": "mailto:[email protected]?Subject=Feedback from vxFormsUI",
"UserRole": userRoleInfo.UserRole,
"UserEmail": userRoleInfo.UserInfo.Email, // Add user email here if available
"ReturnURL": userRoleInfo.UserInfo.ReturnURL, // Add return URL here if available
"forms": templates,
}

c.HTML(http.StatusOK, "index.html", data)
})

api.GET("/form/:name", func(c *gin.Context) {
name := c.Param("name")
id := c.Param("id")
Expand All @@ -140,6 +167,7 @@ func main() {
"form": selected,
"jobSpecIDs": jobSpecIDs,
"id": id, // Pass the id parameter to the template
"UserRole": c.Query("userRole"),
})
})

Expand Down
77 changes: 55 additions & 22 deletions static/js/indexPage.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
// indexPage.js
// JavaScript extracted from index.html

// Declare custom window property
window.authenticatedMode = null;
async function checkAllowed() {
// There is a neeed to both disable and hide elements based on authenticated mode
// even for mode vx which has full access, because some elements may be
Expand Down Expand Up @@ -50,7 +48,7 @@ async function checkAllowed() {
for (let i = 0; i < allFieldIds.length; i++) {
elementId = allFieldIds[i];
elem = document.getElementById(elementId)
switch (window.authenticatedMode) {
switch (window.authenticatedRole) {
case "vx":
if (vxDisallowedElementIds && vxDisallowedElementIds.includes(elementId)) {
elem && (elem.disabled = true);
Expand All @@ -74,7 +72,7 @@ async function checkAllowed() {
break;
}
// check if the element is to be displayed in the current authenticated mode
switch (window.authenticatedMode) {
switch (window.authenticatedRole) {
case "vx":
if (vxHiddenElementIds && vxHiddenElementIds.includes(elementId)) {
elem && (elem.style.display = "none");
Expand All @@ -100,32 +98,64 @@ async function checkAllowed() {
}
}

async function logout() {
window.authenticatedRole = null;
window.userEmail = null;
const whoamiElement = document.getElementById("whoami");
const logoutElement = document.getElementById("logout");
const authenticateElement = document.getElementById("authenticate");
document.getElementById("mainContent").style.display = "none";
document.getElementById("dataSourceRequest").style.display = "none";
whoamiElement.style.color = "red";
whoamiElement.innerText = `Authenticate:`;
logoutElement.style.color = "LightGreen";
logoutElement.innerText = "";
logoutElement.style.display = "none";
}

async function authenticate() {
const creds = await getPasswordModal();
if (!window.vxFormsCredentials
|| !Object.values(window.vxFormsCredentials).includes(creds.password.toLowerCase())
|| !Object.keys(window.vxFormsCredentials).includes(creds.user.toLowerCase())
|| window.vxFormsCredentials[creds.user.toLowerCase()].toLowerCase() !== creds.password.toLowerCase()) {
alert("Incorrect credentials.");
document.getElementById("youNeedToAuthenticate").style.display = "block";
document.getElementById("mainContent").style.display = "none";
document.getElementById("dataSourceRequest").style.display = "none";
document.getElementById("authenticate").style.whiteSpace = "normal";
document.getElementById("whoami").style.color = "red";
document.getElementById("whoami").innerText = `Authenticate:`;
return;
if (!window.authenticatedRole) {
if (window.returnURL !== window.ROOT_PATH) {
const creds = await getPasswordModal();
if (!window.vxFormsCredentials
|| !Object.values(window.vxFormsCredentials).includes(creds.password.toLowerCase())
|| !Object.keys(window.vxFormsCredentials).includes(creds.user.toLowerCase())
|| window.vxFormsCredentials[creds.user.toLowerCase()].toLowerCase() !== creds.password.toLowerCase()) {
alert("Incorrect credentials.");
document.getElementById("youNeedToAuthenticate").style.display = "block";
document.getElementById("mainContent").style.display = "none";
document.getElementById("dataSourceRequest").style.display = "none";
document.getElementById("authenticate").style.whiteSpace = "normal";
document.getElementById("whoami").style.color = "red";
document.getElementById("whoami").innerText = `Authenticate:`;
document.getElementById("logout").style.color = "red";
document.getElementById("logout").innerText = "";
return;
}
window.authenticatedRole = Object.keys(window.vxFormsCredentials).find(k => window.vxFormsCredentials[k] === creds.password);
} else {
window.location.href = "https://apps.gsl.noaa.gov/ssop/login/6"
return;
}
}
window.authenticatedMode = Object.keys(window.vxFormsCredentials).find(k => window.vxFormsCredentials[k] === creds.password);
document.getElementById("mainContent").style.display = "block";
document.getElementById("dataSourceRequest").style.display = "block";
const whoamiElement = document.getElementById("whoami");
whoamiElement.innerText = `Authenticated as: ${window.authenticatedMode}`;
const logoutElement = document.getElementById("logout");
const youNeedToAuthenticateElement = document.getElementById("youNeedToAuthenticate")
const whoamiContainer = document.getElementById("authenticate");
if (!window.userEmail) {
window.userEmail = "unknown";
}
whoamiElement.innerText = `Authenticated as:${window.userEmail} (${window.authenticatedRole})`;
whoamiContainer.style.whiteSpace = "nowrap";
whoamiContainer.style.overflow = "hidden";
whoamiContainer.style.textOverflow = "ellipsis";
whoamiElement.style.color = "green";
document.getElementById("youNeedToAuthenticate").style.display = "none";
whoamiElement.style.color = "lightGreen";
logoutElement.style.color = "red";
youNeedToAuthenticateElement.style.display = "none";
logoutElement.style.display = "block";
logoutElement.innerText = "logout";
}

// To show the prompt and get the value, returns a Promise that resolves with the password
Expand Down Expand Up @@ -173,7 +203,7 @@ function commitJobSet() {
}

async function viewAssociatedJobSet() {
if (window.authenticatedMode != 'vx') {
if (window.authenticatedRole != 'vx') {
document.getElementById("jsonJobSetModalContent").style.display = "none";
return;
}
Expand Down Expand Up @@ -370,4 +400,7 @@ document.addEventListener("DOMContentLoaded", function () {
document.getElementById("dsrContent").innerHTML =
'<div class="alert alert-danger">Failed to load form.</div>';
});
authenticate().then(() => {
checkAllowed();
});
});
Loading
Loading