From ccdbcce9c0b9c25904a3e7b71ab3ad08d5982b8b Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Fri, 6 Jun 2025 15:18:44 -0700 Subject: [PATCH] Add client package. --- client/README.org | 177 ++++++++++++++++++++++++++++++++++ client/auth.go | 212 +++++++++++++++++++++++++++++++++++++++++ client/client.go | 74 ++++++++++++++ client/client_test.go | 80 ++++++++++++++++ client/database.go | 76 +++++++++++++++ client/example_test.go | 137 ++++++++++++++++++++++++++ 6 files changed, 756 insertions(+) create mode 100644 client/README.org create mode 100644 client/auth.go create mode 100644 client/client.go create mode 100644 client/client_test.go create mode 100644 client/database.go create mode 100644 client/example_test.go diff --git a/client/README.org b/client/README.org new file mode 100644 index 0000000..5b4f9c6 --- /dev/null +++ b/client/README.org @@ -0,0 +1,177 @@ +#+TITLE: MCIAS Client SDK + +The MCIAS Client SDK provides a Go client for interacting with the Metacircular Identity and Access System (MCIAS). It allows applications to authenticate users and retrieve database credentials from an MCIAS server. + +* Installation + +#+BEGIN_SRC bash +go get git.wntrmute.dev/kyle/mcias/client +#+END_SRC + +* Usage + +** Creating a Client + +#+BEGIN_SRC go +import "git.wntrmute.dev/kyle/mcias/client" + +// Create a client with default settings (connects to http://localhost:8080) +c := client.NewClient() + +// Create a client with custom settings +c := client.NewClient( + client.WithBaseURL("https://mcias.example.com"), + client.WithUsername("username"), + client.WithToken("existing-token"), +) +#+END_SRC + +** Authentication + +*** Password Authentication + +#+BEGIN_SRC go +ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +defer cancel() + +tokenResp, err := c.LoginWithPassword(ctx, "username", "password") +if err != nil { + log.Fatalf("Failed to login: %v", err) +} + +fmt.Printf("Authenticated with token: %s\n", tokenResp.Token) +fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339)) + +// Check if TOTP verification is required +if tokenResp.TOTPEnabled { + fmt.Println("TOTP verification required") + // See TOTP Verification section +} +#+END_SRC + +*** Token Authentication + +#+BEGIN_SRC go +ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +defer cancel() + +tokenResp, err := c.LoginWithToken(ctx, "username", "existing-token") +if err != nil { + log.Fatalf("Failed to login with token: %v", err) +} + +fmt.Printf("Authenticated with token: %s\n", tokenResp.Token) +fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339)) +#+END_SRC + +*** TOTP Verification + +If TOTP is enabled for a user, you'll need to verify a TOTP code after password authentication: + +#+BEGIN_SRC go +ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +defer cancel() + +totpResp, err := c.VerifyTOTP(ctx, "username", "123456") // Replace with actual TOTP code +if err != nil { + log.Fatalf("Failed to verify TOTP: %v", err) +} + +fmt.Printf("TOTP verified, token: %s\n", totpResp.Token) +fmt.Printf("Token expires at: %s\n", time.Unix(totpResp.Expires, 0).Format(time.RFC3339)) +#+END_SRC + +** Retrieving Database Credentials + +Once authenticated, you can retrieve database credentials: + +#+BEGIN_SRC go +ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +defer cancel() + +dbCreds, err := c.GetDatabaseCredentials(ctx) +if err != nil { + log.Fatalf("Failed to get database credentials: %v", err) +} + +fmt.Printf("Database Host: %s\n", dbCreds.Host) +fmt.Printf("Database Port: %d\n", dbCreds.Port) +fmt.Printf("Database Name: %s\n", dbCreds.Name) +fmt.Printf("Database User: %s\n", dbCreds.User) +fmt.Printf("Database Password: %s\n", dbCreds.Password) +#+END_SRC + +* Complete Example + +Here's a complete example showing the authentication flow and database credential retrieval: + +#+BEGIN_SRC go +package main + +import ( + "context" + "fmt" + "log" + "time" + + "git.wntrmute.dev/kyle/mcias/client" +) + +func main() { + // Create a new client + c := client.NewClient() + + // Create a context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Authenticate with username and password + tokenResp, err := c.LoginWithPassword(ctx, "username", "password") + if err != nil { + log.Fatalf("Failed to login: %v", err) + } + + fmt.Printf("Authenticated with token: %s\n", tokenResp.Token) + fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339)) + + // If TOTP is enabled, verify the TOTP code + if tokenResp.TOTPEnabled { + fmt.Println("TOTP is enabled, please enter your TOTP code") + var totpCode string + fmt.Scanln(&totpCode) + + totpResp, err := c.VerifyTOTP(ctx, "username", totpCode) + if err != nil { + log.Fatalf("Failed to verify TOTP: %v", err) + } + + fmt.Printf("TOTP verified, new token: %s\n", totpResp.Token) + fmt.Printf("Token expires at: %s\n", time.Unix(totpResp.Expires, 0).Format(time.RFC3339)) + } + + // Get database credentials + dbCreds, err := c.GetDatabaseCredentials(ctx) + if err != nil { + log.Fatalf("Failed to get database credentials: %v", err) + } + + fmt.Printf("Database Host: %s\n", dbCreds.Host) + fmt.Printf("Database Port: %d\n", dbCreds.Port) + fmt.Printf("Database Name: %s\n", dbCreds.Name) + fmt.Printf("Database User: %s\n", dbCreds.User) + fmt.Printf("Database Password: %s\n", dbCreds.Password) +} +#+END_SRC + +* Error Handling + +All methods return errors that should be checked. The errors include detailed information about what went wrong, including API error messages when available. + +* Configuration Options + +The client can be configured with the following options: + +- =WithBaseURL(baseURL string)=: Sets the base URL of the MCIAS server (default: "http://localhost:8080") +- =WithHTTPClient(httpClient *http.Client)=: Sets a custom HTTP client (default: http.Client with 10s timeout) +- =WithToken(token string)=: Sets an authentication token +- =WithUsername(username string)=: Sets a username \ No newline at end of file diff --git a/client/auth.go b/client/auth.go new file mode 100644 index 0000000..4e1f10e --- /dev/null +++ b/client/auth.go @@ -0,0 +1,212 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// LoginParams represents the parameters for login. +type LoginParams struct { + User string `json:"user"` + Password string `json:"password,omitempty"` + Token string `json:"token,omitempty"` + TOTPCode string `json:"totp_code,omitempty"` +} + +// LoginRequest represents a login request to the MCIAS API. +type LoginRequest struct { + Version string `json:"version"` + Login LoginParams `json:"login"` +} + +// TOTPVerifyRequest represents a TOTP verification request. +type TOTPVerifyRequest struct { + Version string `json:"version"` + Username string `json:"username"` + TOTPCode string `json:"totp_code"` +} + +// TokenResponse represents the response from a login or TOTP verification request. +type TokenResponse struct { + Token string `json:"token"` + Expires int64 `json:"expires"` + TOTPEnabled bool `json:"totp_enabled,omitempty"` +} + +// ErrorResponse represents an error response from the API. +type ErrorResponse struct { + Error string `json:"error"` + ErrorCode string `json:"error_code,omitempty"` +} + +// LoginWithPassword authenticates with the MCIAS server using a username and password. +// If TOTP is enabled for the user, the TOTPEnabled field in the response will be true, +// and the client will need to call VerifyTOTP to complete authentication. +func (c *Client) LoginWithPassword(ctx context.Context, username, password string) (*TokenResponse, error) { + loginReq := LoginRequest{ + Version: "v1", + Login: LoginParams{ + User: username, + Password: password, + }, + } + + jsonData, err := json.Marshal(loginReq) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + url := fmt.Sprintf("%s/v1/login/password", c.BaseURL) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var errResp ErrorResponse + if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil { + return nil, fmt.Errorf("API error: %s (code: %s)", errResp.Error, errResp.ErrorCode) + } + return nil, fmt.Errorf("API error: %s", resp.Status) + } + + var tokenResp TokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Update client with authentication info + c.Token = tokenResp.Token + c.Username = username + + return &tokenResp, nil +} + +// LoginWithToken authenticates with the MCIAS server using a token. +func (c *Client) LoginWithToken(ctx context.Context, username, token string) (*TokenResponse, error) { + loginReq := LoginRequest{ + Version: "v1", + Login: LoginParams{ + User: username, + Token: token, + }, + } + + jsonData, err := json.Marshal(loginReq) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + url := fmt.Sprintf("%s/v1/login/token", c.BaseURL) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var errResp ErrorResponse + if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil { + return nil, fmt.Errorf("API error: %s (code: %s)", errResp.Error, errResp.ErrorCode) + } + return nil, fmt.Errorf("API error: %s", resp.Status) + } + + var tokenResp TokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Update client with authentication info + c.Token = tokenResp.Token + c.Username = username + + return &tokenResp, nil +} + +// VerifyTOTP verifies a TOTP code for a user. +func (c *Client) VerifyTOTP(ctx context.Context, username, totpCode string) (*TokenResponse, error) { + totpReq := TOTPVerifyRequest{ + Version: "v1", + Username: username, + TOTPCode: totpCode, + } + + jsonData, err := json.Marshal(totpReq) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + url := fmt.Sprintf("%s/v1/login/totp", c.BaseURL) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var errResp ErrorResponse + if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil { + return nil, fmt.Errorf("API error: %s (code: %s)", errResp.Error, errResp.ErrorCode) + } + return nil, fmt.Errorf("API error: %s", resp.Status) + } + + var tokenResp TokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Update client with authentication info + c.Token = tokenResp.Token + c.Username = username + + return &tokenResp, nil +} + +// IsTokenExpired checks if the token is expired. +func (c *Client) IsTokenExpired(expiryTime int64) bool { + return expiryTime > 0 && expiryTime < time.Now().Unix() +} diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..23d93ce --- /dev/null +++ b/client/client.go @@ -0,0 +1,74 @@ +// Package client provides a Go client for interacting with the MCIAS (Metacircular Identity and Access System). +package client + +import ( + "net/http" + "time" +) + +// Client is the main struct for interacting with the MCIAS API. +type Client struct { + // BaseURL is the base URL of the MCIAS server. + BaseURL string + + // HTTPClient is the HTTP client used for making requests. + HTTPClient *http.Client + + // Token is the authentication token. + Token string + + // Username is the authenticated username. + Username string +} + +// ClientOption is a function that configures a Client. +type ClientOption func(*Client) + +// WithBaseURL sets the base URL for the client. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) { + c.BaseURL = baseURL + } +} + +// WithHTTPClient sets the HTTP client for the client. +func WithHTTPClient(httpClient *http.Client) ClientOption { + return func(c *Client) { + c.HTTPClient = httpClient + } +} + +// WithToken sets the authentication token for the client. +func WithToken(token string) ClientOption { + return func(c *Client) { + c.Token = token + } +} + +// WithUsername sets the username for the client. +func WithUsername(username string) ClientOption { + return func(c *Client) { + c.Username = username + } +} + +// NewClient creates a new MCIAS client with the given options. +func NewClient(options ...ClientOption) *Client { + client := &Client{ + BaseURL: "http://localhost:8080", + HTTPClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } + + for _, option := range options { + option(client) + } + + return client +} + +// IsAuthenticated returns true if the client has a token. +func (c *Client) IsAuthenticated() bool { + return c.Token != "" +} diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 0000000..12147c8 --- /dev/null +++ b/client/client_test.go @@ -0,0 +1,80 @@ +package client + +import ( + "net/http" + "testing" + "time" +) + +func TestNewClient(t *testing.T) { + // Test default client + client := NewClient() + if client.BaseURL != "http://localhost:8080" { + t.Errorf("Expected BaseURL to be http://localhost:8080, got %s", client.BaseURL) + } + if client.HTTPClient == nil { + t.Error("Expected HTTPClient to be initialized") + } + if client.Token != "" { + t.Errorf("Expected Token to be empty, got %s", client.Token) + } + if client.Username != "" { + t.Errorf("Expected Username to be empty, got %s", client.Username) + } + + // Test client with options + customHTTPClient := &http.Client{Timeout: 5 * time.Second} + client = NewClient( + WithBaseURL("https://mcias.example.com"), + WithHTTPClient(customHTTPClient), + WithToken("test-token"), + WithUsername("test-user"), + ) + if client.BaseURL != "https://mcias.example.com" { + t.Errorf("Expected BaseURL to be https://mcias.example.com, got %s", client.BaseURL) + } + if client.HTTPClient != customHTTPClient { + t.Error("Expected HTTPClient to be the custom client") + } + if client.Token != "test-token" { + t.Errorf("Expected Token to be test-token, got %s", client.Token) + } + if client.Username != "test-user" { + t.Errorf("Expected Username to be test-user, got %s", client.Username) + } +} + +func TestIsAuthenticated(t *testing.T) { + // Test unauthenticated client + client := NewClient() + if client.IsAuthenticated() { + t.Error("Expected IsAuthenticated to return false for client without token") + } + + // Test authenticated client + client = NewClient(WithToken("test-token")) + if !client.IsAuthenticated() { + t.Error("Expected IsAuthenticated to return true for client with token") + } +} + +func TestIsTokenExpired(t *testing.T) { + client := NewClient() + + // Test expired token + pastTime := time.Now().Add(-1 * time.Hour).Unix() + if !client.IsTokenExpired(pastTime) { + t.Error("Expected IsTokenExpired to return true for past time") + } + + // Test valid token + futureTime := time.Now().Add(1 * time.Hour).Unix() + if client.IsTokenExpired(futureTime) { + t.Error("Expected IsTokenExpired to return false for future time") + } + + // Test zero time (no expiry) + if client.IsTokenExpired(0) { + t.Error("Expected IsTokenExpired to return false for zero time") + } +} diff --git a/client/database.go b/client/database.go new file mode 100644 index 0000000..8b4a8f3 --- /dev/null +++ b/client/database.go @@ -0,0 +1,76 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +// DatabaseCredentials represents the database connection credentials. +type DatabaseCredentials struct { + Host string `json:"host"` + Port int `json:"port"` + Name string `json:"name"` + User string `json:"user"` + Password string `json:"password"` +} + +// GetDatabaseCredentials retrieves database credentials from the MCIAS server. +// This method requires the client to be authenticated (have a valid token). +func (c *Client) GetDatabaseCredentials(ctx context.Context) (*DatabaseCredentials, error) { + if !c.IsAuthenticated() { + return nil, fmt.Errorf("client is not authenticated, call LoginWithPassword or LoginWithToken first") + } + + if c.Username == "" { + return nil, fmt.Errorf("username is not set, call LoginWithPassword or LoginWithToken first") + } + + // Build the URL with query parameters + baseURL := fmt.Sprintf("%s/v1/database/credentials", c.BaseURL) + params := url.Values{} + params.Add("username", c.Username) + requestURL := fmt.Sprintf("%s?%s", baseURL, params.Encode()) + + // Create the request + req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Add authorization header + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token)) + + // Send the request + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Check for errors + if resp.StatusCode != http.StatusOK { + var errResp ErrorResponse + if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil { + return nil, fmt.Errorf("API error: %s (code: %s)", errResp.Error, errResp.ErrorCode) + } + return nil, fmt.Errorf("API error: %s", resp.Status) + } + + // Parse the response + var creds DatabaseCredentials + if err := json.Unmarshal(body, &creds); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &creds, nil +} diff --git a/client/example_test.go b/client/example_test.go new file mode 100644 index 0000000..9e8fa50 --- /dev/null +++ b/client/example_test.go @@ -0,0 +1,137 @@ +package client_test + +import ( + "context" + "fmt" + "log" + "time" + + "git.wntrmute.dev/kyle/mcias/client" +) + +func Example() { + // Create a new client with default settings + c := client.NewClient() + + // Create a context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Authenticate with username and password + tokenResp, err := c.LoginWithPassword(ctx, "username", "password") + if err != nil { + log.Fatalf("Failed to login: %v", err) + } + + fmt.Printf("Authenticated with token: %s\n", tokenResp.Token) + fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339)) + + // If TOTP is enabled, verify the TOTP code + if tokenResp.TOTPEnabled { + fmt.Println("TOTP is enabled, please enter your TOTP code") + totpCode := "123456" // In a real application, this would be user input + + totpResp, err := c.VerifyTOTP(ctx, "username", totpCode) + if err != nil { + log.Fatalf("Failed to verify TOTP: %v", err) + } + + fmt.Printf("TOTP verified, new token: %s\n", totpResp.Token) + fmt.Printf("Token expires at: %s\n", time.Unix(totpResp.Expires, 0).Format(time.RFC3339)) + } + + // Get database credentials + dbCreds, err := c.GetDatabaseCredentials(ctx) + if err != nil { + log.Fatalf("Failed to get database credentials: %v", err) + } + + fmt.Printf("Database Host: %s\n", dbCreds.Host) + fmt.Printf("Database Port: %d\n", dbCreds.Port) + fmt.Printf("Database Name: %s\n", dbCreds.Name) + fmt.Printf("Database User: %s\n", dbCreds.User) + fmt.Printf("Database Password: %s\n", dbCreds.Password) + + // Example of authenticating with a token + tokenClient := client.NewClient() + tokenResp, err = tokenClient.LoginWithToken(ctx, "username", "existing-token") + if err != nil { + log.Fatalf("Failed to login with token: %v", err) + } + + fmt.Printf("Authenticated with token: %s\n", tokenResp.Token) + fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339)) +} + +func ExampleClient_LoginWithPassword() { + c := client.NewClient( + client.WithBaseURL("https://mcias.example.com"), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tokenResp, err := c.LoginWithPassword(ctx, "username", "password") + if err != nil { + log.Fatalf("Failed to login: %v", err) + } + + fmt.Printf("Authenticated with token: %s\n", tokenResp.Token) + fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339)) + + if tokenResp.TOTPEnabled { + fmt.Println("TOTP verification required") + } +} + +func ExampleClient_LoginWithToken() { + c := client.NewClient() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tokenResp, err := c.LoginWithToken(ctx, "username", "existing-token") + if err != nil { + log.Fatalf("Failed to login with token: %v", err) + } + + fmt.Printf("Authenticated with token: %s\n", tokenResp.Token) + fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339)) +} + +func ExampleClient_VerifyTOTP() { + c := client.NewClient() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + totpResp, err := c.VerifyTOTP(ctx, "username", "123456") + if err != nil { + log.Fatalf("Failed to verify TOTP: %v", err) + } + + fmt.Printf("TOTP verified, token: %s\n", totpResp.Token) + fmt.Printf("Token expires at: %s\n", time.Unix(totpResp.Expires, 0).Format(time.RFC3339)) +} + +func ExampleClient_GetDatabaseCredentials() { + // Create a client with pre-configured authentication + c := client.NewClient( + client.WithUsername("username"), + client.WithToken("existing-token"), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + dbCreds, err := c.GetDatabaseCredentials(ctx) + if err != nil { + log.Fatalf("Failed to get database credentials: %v", err) + } + + fmt.Printf("Database Host: %s\n", dbCreds.Host) + fmt.Printf("Database Port: %d\n", dbCreds.Port) + fmt.Printf("Database Name: %s\n", dbCreds.Name) + fmt.Printf("Database User: %s\n", dbCreds.User) + fmt.Printf("Database Password: %s\n", dbCreds.Password) +}