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
29 changes: 22 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,12 @@ Add your Tableau settings in `appsettings.json`:

```json
{
"Tableau": {
"ServerUrl": "https://your-tableau-server",
"TableauOptions": {
"Server": "https://your-tableau-server",
"Version": "3.23",
"Site": "yoursite"
},
"TableauAuthOptions": {
"SiteContentUrl": "yoursite",
"PersonalAccessTokenName": "your-token-name",
"PersonalAccessTokenSecret": "your-token-secret",
Expand All @@ -61,18 +65,29 @@ using TableauSharp.Extensions;

var builder = WebApplication.CreateBuilder(args);

// Bind Tableau settings
builder.Services.Configure<TableauAuthOptions>(
builder.Configuration.GetSection("Tableau"));

// Register Tableau services
builder.Services.AddTableauSharp();
builder.Services.AddTableauSharp(builder.Configuration);

var app = builder.Build();
```

---

## Authentication Lifecycle

Call one of the sign-in methods before using site-scoped services:

```csharp
var authToken = await authService.SignInWithPATAsync();
var workbooks = await workbookService.GetAllAsync();
```

Successful sign-in stores the Tableau auth token, site LUID, site content URL, user LUID, and expiration in the scoped Tableau session. Site-scoped REST requests use the signed-in site LUID returned by Tableau, not the friendly site content URL.

The built-in session is scoped for a single logical caller. Server applications that serve multiple Tableau users should create an appropriate DI scope per caller/request or manage user-specific sessions explicitly.

---

## Quick Start

### Fetch Workbooks
Expand Down
120 changes: 75 additions & 45 deletions src/TableauSharp/Auth/Service/AuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public class AuthService(IHttpClientFactory httpClientFactory,
private readonly ITableauTokenProvider _tokenProvider = tokenProvider;
private readonly TableauOptions _tableauOptions = tableauOptions.Value;

public async Task<AuthToken> SignInWithPATAsync()
public async Task<AuthToken> SignInWithPATAsync(CancellationToken cancellationToken = default)
{
var payload = new
{
Expand All @@ -31,22 +31,16 @@ public async Task<AuthToken> SignInWithPATAsync()
}
};
var client = _httpClientFactory.CreateClient("TableauClient");
var response = await client.PostAsync("auth/signin", GetJsonContent(payload));
var response = await client.PostAsync("auth/signin", GetJsonContent(payload), cancellationToken);
response.EnsureSuccessStatusCode();

var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
_tokenProvider.SetToken(doc.RootElement.GetProperty("credentials").GetProperty("token").GetString()!);
return new AuthToken
{
Token = doc.RootElement.GetProperty("credentials").GetProperty("token").GetString()!,
SiteId = doc.RootElement.GetProperty("credentials").GetProperty("site").GetProperty("id").GetString()!,
UserId = doc.RootElement.GetProperty("credentials").GetProperty("user").GetProperty("id").GetString()!,
Expiration = DateTime.UtcNow.AddHours(2)
};
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var authToken = ParseAuthToken(json);
_tokenProvider.SetToken(authToken);
return authToken;
}

public async Task<AuthToken> SignInWithUserCredentialsAsync()
public async Task<AuthToken> SignInWithUserCredentialsAsync(CancellationToken cancellationToken = default)
{
var payload = new
{
Expand All @@ -58,28 +52,23 @@ public async Task<AuthToken> SignInWithUserCredentialsAsync()
}
};
var client = _httpClientFactory.CreateClient("TableauClient");
var response = await client.PostAsync("auth/signin", GetJsonContent(payload));
var response = await client.PostAsync("auth/signin", GetJsonContent(payload), cancellationToken);
response.EnsureSuccessStatusCode();

var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);

return new AuthToken
{
Token = doc.RootElement.GetProperty("credentials").GetProperty("token").GetString()!,
SiteId = doc.RootElement.GetProperty("credentials").GetProperty("site").GetProperty("id").GetString()!,
UserId = doc.RootElement.GetProperty("credentials").GetProperty("user").GetProperty("id").GetString()!,
Expiration = DateTime.UtcNow.AddMinutes(_options.Jwt_Expiry_Minutes)
};
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var authToken = ParseAuthToken(json);
_tokenProvider.SetToken(authToken);
return authToken;
}

public async Task SignOutAsync(string token)
public async Task SignOutAsync(string token, CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "auth/signout");
request.Headers.Add("X-Tableau-Auth", token);
var client = _httpClientFactory.CreateClient("TableauClient");
var response = await client.SendAsync(request);
var response = await client.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
_tokenProvider.ClearToken();
}

private static StringContent GetJsonContent(object payload)
Expand All @@ -88,37 +77,63 @@ private static StringContent GetJsonContent(object payload)
return new StringContent(json, Encoding.UTF8, "application/json");
}

public async Task<AuthToken> SignInWithJWTAsync(string username, CancellationToken cancellationToken)
public async Task<AuthToken> SignInWithJWTAsync(string username, CancellationToken cancellationToken = default)
{
string[] scopes = _options.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var payload = new
{
credentials = new
{
jwt = CreateJwtToken(username),
site = new { contentUrl = _tableauOptions.Site}
jwt = CreateJwt(username, scopes),
site = new { contentUrl = GetSiteContentUrl() }
}
};
var client = _httpClientFactory.CreateClient("TableauClient");
var response = await client.PostAsync("auth/signin", GetJsonContent(payload));
var response = await client.PostAsync("auth/signin", GetJsonContent(payload), cancellationToken);
response.EnsureSuccessStatusCode();

var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var authToken = ParseAuthToken(json);
_tokenProvider.SetToken(authToken);
return authToken;
}

private AuthToken ParseAuthToken(string json)
{
using var doc = JsonDocument.Parse(json);
var credentials = doc.RootElement.GetProperty("credentials");
var site = credentials.GetProperty("site");
var user = credentials.GetProperty("user");

return new AuthToken
{
Token = doc.RootElement.GetProperty("credentials").GetProperty("token").GetString()!,
SiteId = doc.RootElement.GetProperty("credentials").GetProperty("site").GetProperty("id").GetString()!,
UserId = doc.RootElement.GetProperty("credentials").GetProperty("user").GetProperty("id").GetString()!,
Expiration = DateTime.UtcNow.AddHours(2)
Token = credentials.GetProperty("token").GetString() ?? string.Empty,
SiteId = site.GetProperty("id").GetString() ?? string.Empty,
SiteContentUrl = site.TryGetProperty("contentUrl", out var contentUrl)
? contentUrl.GetString()
: GetSiteContentUrl(),
UserId = user.GetProperty("id").GetString() ?? string.Empty,
Expiration = GetExpiration(credentials)
};
}

private string CreateJwt(string username, string[] scopes)
{
if (string.IsNullOrWhiteSpace(_options.SecretValue))
{
throw new InvalidOperationException("TableauAuthOptions.SecretValue must be configured for JWT sign-in.");
}

if (string.IsNullOrWhiteSpace(_options.SecretId))
{
throw new InvalidOperationException("TableauAuthOptions.SecretId must be configured for JWT sign-in.");
}

SecurityTokenDescriptor tokenDescriptor = new()
{
Audience = _options.Jwt_Audience,
Audience = string.IsNullOrWhiteSpace(_options.Jwt_Audience)
? _tableauOptions.Server
: _options.Jwt_Audience,
Subject = new System.Security.Claims.ClaimsIdentity(new[]
{
new System.Security.Claims.Claim("sub", username),
Expand All @@ -133,15 +148,30 @@ private string CreateJwt(string username, string[] scopes)
return jsonWebTokenHandler.CreateToken(tokenDescriptor);
}

private TableauJWT CreateJwtToken(string username)
private DateTime GetExpiration(JsonElement credentials)
{
string[] scopes = _options.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return new TableauJWT
if (credentials.TryGetProperty("estimatedTimeToExpiration", out var expirationElement))
{
ExpiryMinutes = _options.Jwt_Expiry_Minutes,
Server = _tableauOptions.Server,
Site = _tableauOptions.Site,
Token = CreateJwt(username, scopes)
};
var value = expirationElement.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
var parts = value.Split(':');
if (parts.Length == 3
&& int.TryParse(parts[0], out var days)
&& int.TryParse(parts[1], out var hours)
&& int.TryParse(parts[2], out var minutes))
{
return DateTime.UtcNow.Add(new TimeSpan(days, hours, minutes, 0));
}
}
}

var fallbackMinutes = _options.Jwt_Expiry_Minutes > 0 ? _options.Jwt_Expiry_Minutes : 120;
return DateTime.UtcNow.AddMinutes(fallbackMinutes);
}
}

private string GetSiteContentUrl()
=> !string.IsNullOrWhiteSpace(_options.SiteContentUrl)
? _options.SiteContentUrl
: _tableauOptions.Site;
}
11 changes: 5 additions & 6 deletions src/TableauSharp/Auth/Service/IAuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ namespace TableauSharp.Auth.Service;

public interface IAuthService
{
Task<AuthToken> SignInWithPATAsync();
Task<AuthToken> SignInWithUserCredentialsAsync();
Task SignOutAsync(string token);
Task<AuthToken> SignInWithJWTAsync(string username, CancellationToken cancellationToken);

}
Task<AuthToken> SignInWithPATAsync(CancellationToken cancellationToken = default);
Task<AuthToken> SignInWithUserCredentialsAsync(CancellationToken cancellationToken = default);
Task SignOutAsync(string token, CancellationToken cancellationToken = default);
Task<AuthToken> SignInWithJWTAsync(string username, CancellationToken cancellationToken = default);
}
14 changes: 13 additions & 1 deletion src/TableauSharp/Common/Helper/ITableauTokenProvider.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using TableauSharp.Common.Models;

namespace TableauSharp.Common.Helper;

public interface ITableauTokenProvider
Expand All @@ -7,13 +9,23 @@ public interface ITableauTokenProvider
/// </summary>
string GetToken();

/// <summary>
/// Gets the current Tableau authentication/session details.
/// </summary>
AuthToken GetTokenInfo();

/// <summary>
/// Sets or updates the Tableau authentication token.
/// </summary>
void SetToken(string token);

/// <summary>
/// Sets or updates the Tableau authentication/session details.
/// </summary>
void SetToken(AuthToken token);

/// <summary>
/// Clears the current token (e.g., after sign out).
/// </summary>
void ClearToken();
}
}
49 changes: 42 additions & 7 deletions src/TableauSharp/Common/Helper/TableauTokenProvider.cs
Original file line number Diff line number Diff line change
@@ -1,24 +1,59 @@
using TableauSharp.Common.Models;

namespace TableauSharp.Common.Helper;

public class TableauTokenProvider : ITableauTokenProvider
{
private string _token;
private readonly object _gate = new();
private AuthToken? _token;

public string GetToken()
{
if (string.IsNullOrWhiteSpace(_token))
throw new InvalidOperationException("No Tableau auth token set. Please sign in first.");
return GetTokenInfo().Token;
}

return _token;
public AuthToken GetTokenInfo()
{
lock (_gate)
{
if (_token is null || string.IsNullOrWhiteSpace(_token.Token))
{
throw new InvalidOperationException("No Tableau auth token set. Please sign in first.");
}

return _token;
}
}

public void SetToken(string token)
{
_token = token ?? throw new ArgumentNullException(nameof(token));
if (string.IsNullOrWhiteSpace(token))
{
throw new ArgumentException("Token cannot be null or empty.", nameof(token));
}

SetToken(new AuthToken { Token = token });
}

public void SetToken(AuthToken token)
{
ArgumentNullException.ThrowIfNull(token);
if (string.IsNullOrWhiteSpace(token.Token))
{
throw new ArgumentException("Token cannot be null or empty.", nameof(token));
}

lock (_gate)
{
_token = token;
}
}

public void ClearToken()
{
_token = null;
lock (_gate)
{
_token = null;
}
}
}
}
7 changes: 7 additions & 0 deletions src/TableauSharp/Common/Http/ITableauRequestBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace TableauSharp.Common.Http;

public interface ITableauRequestBuilder
{
HttpRequestMessage CreateSiteRequest(HttpMethod method, string relativePath);
HttpRequestMessage CreateServerRequest(HttpMethod method, string relativePath);
}
Loading
Loading