diff --git a/Controllers/AuthController.cs b/Controllers/AuthController.cs new file mode 100644 index 0000000..0a1a42e --- /dev/null +++ b/Controllers/AuthController.cs @@ -0,0 +1,207 @@ +using System.Net; +using System.Text.Json; +using Fido2NetLib; +using GAToolAPI.Models; +using GAToolAPI.Services.Auth; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using NSwag.Annotations; + +namespace GAToolAPI.Controllers; + +[ApiController] +[Route("v3/auth")] +[OpenApiTag("Authentication")] +[EnableCors("AuthOrigins")] +public class AuthController( + OtpService otp, + TokenService tokens, + PasskeyService passkeys, + AuthRepository repo, + ILogger logger) : ControllerBase +{ + private string? UserAgent => Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null; + + private static bool LooksLikeEmail(string s) => + !string.IsNullOrWhiteSpace(s) && s.Contains('@') && s.Length <= 254; + + // ── OTP login ──────────────────────────────────────────────────────────── + + /// Request a one-time login code be emailed to the given address. + /// Code sent (or rate-limited; response is the same to avoid email enumeration). + [HttpPost("otp/request")] + [AllowAnonymous] + [ProducesResponseType((int)HttpStatusCode.NoContent)] + public async Task RequestOtp([FromBody] OtpRequestBody body, CancellationToken ct) + { + if (!LooksLikeEmail(body.Email)) + return BadRequest(new { message = "Invalid email" }); + + var result = await otp.IssueAsync(body.Email, ct); + // Always return 204 — never reveal whether the email is rate-limited or send failed. + // Operators can check logs / SES bounce metrics for delivery issues. + if (result == OtpService.IssueResult.RateLimited) + logger.LogInformation("OTP rate-limited for {Email}", body.Email); + return NoContent(); + } + + /// Exchange an OTP code for an access token + refresh token. + [HttpPost("otp/verify")] + [AllowAnonymous] + [ProducesResponseType(typeof(TokenResponse), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.Unauthorized)] + public async Task VerifyOtp([FromBody] OtpVerifyBody body, CancellationToken ct) + { + if (!LooksLikeEmail(body.Email) || string.IsNullOrWhiteSpace(body.Code)) + return BadRequest(new { message = "Email and code are required" }); + + var result = await otp.VerifyAsync(body.Email, body.Code, ct); + if (result != OtpService.VerifyResult.Ok) + return Unauthorized(new { message = "Invalid or expired code", reason = result.ToString() }); + + var user = await repo.UpsertUserAsync(body.Email, rolesIfNew: [], ct: ct); + await repo.TouchLoginAsync(user.Email, ct); + var resp = await tokens.IssueTokensAsync(user, UserAgent, ct); + return Ok(resp); + } + + // ── Refresh / logout ───────────────────────────────────────────────────── + + /// Exchange a refresh token for a new access token + refresh token. + [HttpPost("refresh")] + [AllowAnonymous] + [ProducesResponseType(typeof(TokenResponse), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.Unauthorized)] + public async Task Refresh([FromBody] RefreshBody body, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(body.RefreshToken)) + return BadRequest(); + var resp = await tokens.RefreshAsync(body.RefreshToken, UserAgent, ct); + if (resp == null) return Unauthorized(); + return Ok(resp); + } + + /// Revoke the supplied refresh token. + [HttpPost("logout")] + [AllowAnonymous] + [ProducesResponseType((int)HttpStatusCode.NoContent)] + public async Task Logout([FromBody] LogoutBody body, CancellationToken ct) + { + if (!string.IsNullOrWhiteSpace(body.RefreshToken)) + await tokens.RevokeRefreshTokenAsync(body.RefreshToken, ct); + return NoContent(); + } + + // ── Current user info ──────────────────────────────────────────────────── + + /// Get the currently-authenticated user's email, roles, and registered passkeys. + [HttpGet("me")] + [Authorize("user")] + [ProducesResponseType(typeof(MeResponse), (int)HttpStatusCode.OK)] + public async Task Me(CancellationToken ct) + { + var email = User.FindFirst("name")?.Value; + if (string.IsNullOrEmpty(email)) return Unauthorized(); + + var user = await repo.GetUserAsync(email, ct); + if (user == null) return Unauthorized(); + + var pks = await repo.ListPasskeysAsync(email, ct); + return Ok(new MeResponse( + user.Email, + user.Roles, + pks.Select(p => new PasskeyInfo(p.CredentialId, p.Nickname, p.CreatedAt, p.LastUsedAt)) + .ToArray())); + } + + // ── Passkey registration ───────────────────────────────────────────────── + + /// Begin passkey registration. Returns WebAuthn creation options + a session id. + [HttpPost("passkey/register-options")] + [Authorize("user")] + public async Task PasskeyRegisterOptions(CancellationToken ct) + { + var email = User.FindFirst("name")?.Value; + if (string.IsNullOrEmpty(email)) return Unauthorized(); + var result = await passkeys.BeginRegistrationAsync(email, ct); + // Return the options as raw JSON (the JSON the browser expects) plus our sessionId. + return Ok(new + { + sessionId = result.SessionId, + options = JsonDocument.Parse(result.Options.ToJson()).RootElement + }); + } + + public record PasskeyRegisterCompleteRequest( + string SessionId, + string? Nickname, + AuthenticatorAttestationRawResponse Attestation); + + /// Complete passkey registration with the browser's attestation response. + [HttpPost("passkey/register")] + [Authorize("user")] + public async Task PasskeyRegister([FromBody] PasskeyRegisterCompleteRequest body, + CancellationToken ct) + { + var email = User.FindFirst("name")?.Value; + if (string.IsNullOrEmpty(email)) return Unauthorized(); + + var record = await passkeys.CompleteRegistrationAsync(email, body.SessionId, body.Attestation, + body.Nickname, ct); + if (record == null) return BadRequest(new { message = "Registration session expired or invalid" }); + + return Ok(new PasskeyInfo(record.CredentialId, record.Nickname, record.CreatedAt, record.LastUsedAt)); + } + + /// Remove a registered passkey from the current user. + [HttpDelete("passkey/{credentialId}")] + [Authorize("user")] + public async Task PasskeyDelete(string credentialId, CancellationToken ct) + { + var email = User.FindFirst("name")?.Value; + if (string.IsNullOrEmpty(email)) return Unauthorized(); + + // Verify the passkey belongs to the caller before deleting + var existing = await repo.GetPasskeyByCredentialIdAsync(credentialId, ct); + if (existing == null || !string.Equals(existing.Email, email, StringComparison.OrdinalIgnoreCase)) + return NotFound(); + + await repo.DeletePasskeyAsync(email, credentialId, ct); + return NoContent(); + } + + // ── Passkey authentication ─────────────────────────────────────────────── + + /// Begin passkey authentication. Returns WebAuthn assertion options + a session id. + [HttpPost("passkey/auth-options")] + [AllowAnonymous] + public async Task PasskeyAuthOptions([FromBody] PasskeyAuthOptionsBody body, + CancellationToken ct) + { + var result = await passkeys.BeginAuthenticationAsync(body.Email, ct); + return Ok(new + { + sessionId = result.SessionId, + options = JsonDocument.Parse(result.Options.ToJson()).RootElement + }); + } + + public record PasskeyAuthCompleteRequest( + string SessionId, + AuthenticatorAssertionRawResponse Assertion); + + /// Complete passkey authentication and exchange for tokens. + [HttpPost("passkey/authenticate")] + [AllowAnonymous] + public async Task PasskeyAuthenticate([FromBody] PasskeyAuthCompleteRequest body, + CancellationToken ct) + { + var user = await passkeys.CompleteAuthenticationAsync(body.SessionId, body.Assertion, ct); + if (user == null) return Unauthorized(new { message = "Passkey authentication failed" }); + + await repo.TouchLoginAsync(user.Email, ct); + var resp = await tokens.IssueTokensAsync(user, UserAgent, ct); + return Ok(resp); + } +} diff --git a/Models/AuthModels.cs b/Models/AuthModels.cs new file mode 100644 index 0000000..7ae7eaa --- /dev/null +++ b/Models/AuthModels.cs @@ -0,0 +1,93 @@ +using System.Text.Json.Serialization; + +namespace GAToolAPI.Models; + +// ── Public DTOs (request/response shapes) ──────────────────────────────────── + +public record OtpRequestBody(string Email); + +public record OtpVerifyBody(string Email, string Code); + +public record RefreshBody(string RefreshToken); + +public record LogoutBody(string RefreshToken); + +public record TokenResponse( + string AccessToken, + string RefreshToken, + int ExpiresIn, + string Email, + string[] Roles); + +public record MeResponse( + string Email, + string[] Roles, + PasskeyInfo[] Passkeys); + +public record PasskeyInfo( + string CredentialId, + string? Nickname, + DateTimeOffset CreatedAt, + DateTimeOffset? LastUsedAt); + +// ── WebAuthn DTOs (we pass through Fido2 library options as JsonElement) ───── + +public record PasskeyRegisterCompleteBody( + string Nickname, + System.Text.Json.JsonElement AttestationResponse); + +public record PasskeyAuthOptionsBody(string? Email); + +public record PasskeyAuthCompleteBody( + System.Text.Json.JsonElement AssertionResponse); + +// ── Internal storage records (DynamoDB-shaped, but kept as POCOs) ──────────── + +public class UserRecord +{ + public string Email { get; set; } = ""; + public string[] Roles { get; set; } = ["user"]; + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? LastLoginAt { get; set; } +} + +public class PasskeyRecord +{ + public string Email { get; set; } = ""; + public string CredentialId { get; set; } = ""; // base64url + public byte[] PublicKey { get; set; } = []; + public uint SignCount { get; set; } + public Guid AaGuid { get; set; } + public string? Nickname { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? LastUsedAt { get; set; } + public string[] Transports { get; set; } = []; +} + +public class OtpRecord +{ + public string Email { get; set; } = ""; + public string CodeHash { get; set; } = ""; // SHA-256 of the code + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + public int AttemptsRemaining { get; set; } +} + +public class RefreshTokenRecord +{ + public string TokenHash { get; set; } = ""; // SHA-256 of the opaque token + public string Email { get; set; } = ""; + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + public string? UserAgent { get; set; } +} + +// WebAuthn challenge state (cached briefly between options + complete calls) +public class WebAuthnChallengeState +{ + [JsonPropertyName("optionsJson")] + public string OptionsJson { get; set; } = ""; + + [JsonPropertyName("email")] + public string? Email { get; set; } +} diff --git a/Program.cs b/Program.cs index 180f31f..c69fa12 100644 --- a/Program.cs +++ b/Program.cs @@ -1,12 +1,15 @@ using Amazon.DynamoDBv2; using Amazon.S3; using Amazon.SecretsManager; +using Amazon.SimpleEmailV2; +using Fido2NetLib; using GAToolAPI.Attributes; using GAToolAPI.AuthExtensions; using GAToolAPI.Helpers; using GAToolAPI.Jobs; using GAToolAPI.Middleware; using GAToolAPI.Services; +using GAToolAPI.Services.Auth; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Caching.Distributed; @@ -43,11 +46,10 @@ var smClient = new AmazonSecretsManagerClient(); var secretNames = new[] { - "Auth0Issuer", "Auth0Audience", + "Auth0Issuer", "Auth0Audience", // kept during migration window for legacy token validation "FRCApiKey", "TBAApiKey", "FTCApiKey", "CasterstoolApiKey", "TOAApiKey", "FRCCurrentSeason", "FTCCurrentSeason", "MailChimpAPIKey", "MailchimpAPIURL", "MailchimpListID", - "Auth0AdminClientId", "Auth0AdminClientSecret", "NewRelicLicenseKey", "MailchimpWebhookSecret" }; @@ -62,21 +64,83 @@ .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)); - builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => + builder.Services.AddAuthentication(options => { + // Default scheme is our self-issued ES256 JWT. We also keep the Auth0 scheme + // around during the migration window (see AuthenticationSchemes on policies). + options.DefaultAuthenticateScheme = "GatoolJwt"; + options.DefaultChallengeScheme = "GatoolJwt"; + }) + .AddJwtBearer("GatoolJwt", options => + { + // Self-issued JWT: ECDSA P-256 signing key from Secrets Manager. + // Configured asynchronously below once the DI container is built. + options.RequireHttpsMetadata = false; + options.SaveToken = true; + options.Events = new JwtBearerEvents + { + OnMessageReceived = async ctx => + { + if (ctx.Options.TokenValidationParameters.IssuerSigningKey == null) + { + var tokenSvc = ctx.HttpContext.RequestServices + .GetRequiredService(); + var key = await tokenSvc.GetValidationKeyAsync(ctx.HttpContext.RequestAborted); + ctx.Options.TokenValidationParameters = tokenSvc.BuildValidationParameters(key); + } + } + }; + }) + .AddJwtBearer("Auth0", options => + { + // Legacy Auth0 tokens — accepted during migration window. options.Authority = secretProvider.GetSecret("Auth0Issuer"); options.Audience = secretProvider.GetSecret("Auth0Audience"); }); + builder.Services.AddAuthorizationBuilder() - .AddPolicy("user", policy => policy.Requirements.Add(new HasRoleRequirement("user"))) - .AddPolicy("admin", policy => policy.Requirements.Add(new HasRoleRequirement("admin"))); + .AddPolicy("user", policy => + { + policy.AuthenticationSchemes = ["GatoolJwt", "Auth0"]; + policy.Requirements.Add(new HasRoleRequirement("user")); + }) + .AddPolicy("admin", policy => + { + policy.AuthenticationSchemes = ["GatoolJwt", "Auth0"]; + policy.Requirements.Add(new HasRoleRequirement("admin")); + }); builder.Services.AddSingleton(); builder.Services.AddSingleton(secretProvider); builder.Services.AddSingleton(smClient); builder.Services.AddAWSService(); builder.Services.AddAWSService(); + builder.Services.AddAWSService(); + + // Custom auth services (email OTP + WebAuthn passkeys, replaces Auth0) + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // Fido2 / WebAuthn server. ServerDomain is the WebAuthn rpId — must be an apex + // domain or registrable suffix shared by all origins. gatool.org covers + // gatool.org + beta.gatool.org. Localhost dev gets its own override via env. + var fidoConfig = new Fido2Configuration + { + ServerDomain = builder.Configuration["WebAuthn:ServerDomain"] ?? "gatool.org", + ServerName = "gatool", + Origins = (builder.Configuration["WebAuthn:Origins"] + ?? "https://gatool.org,https://beta.gatool.org,http://localhost:3000") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToHashSet(), + TimestampDriftTolerance = 300_000 + }; + builder.Services.AddSingleton(_ => new Fido2(fidoConfig)); builder.Services.AddCors(options => { @@ -84,6 +148,18 @@ .AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader()); + + // Auth endpoints (login, OTP, passkey, refresh) are restricted to the + // first-party UI origins. Other origins can still hit the public read + // endpoints (covered by the default policy above) but cannot initiate + // a login flow against this API. + options.AddPolicy("AuthOrigins", b => b + .WithOrigins( + "https://gatool.org", + "https://beta.gatool.org", + "http://localhost:3000") + .AllowAnyMethod() + .AllowAnyHeader()); }); builder.Services.AddControllers(); diff --git a/Services/Auth/AuthEmailService.cs b/Services/Auth/AuthEmailService.cs new file mode 100644 index 0000000..ede069c --- /dev/null +++ b/Services/Auth/AuthEmailService.cs @@ -0,0 +1,76 @@ +using Amazon.SimpleEmailV2; +using Amazon.SimpleEmailV2.Model; + +namespace GAToolAPI.Services.Auth; + +/// +/// Sends transactional auth emails (one-time login codes) via Amazon SES v2. +/// The sending domain (gatool.org / auth.gatool.org) is already verified in SES. +/// +public class AuthEmailService +{ + private const string FromAddress = "gatool "; + private const string Subject = "Your gatool login code"; + + private readonly IAmazonSimpleEmailServiceV2 _ses; + private readonly ILogger _logger; + + public AuthEmailService(IAmazonSimpleEmailServiceV2 ses, ILogger logger) + { + _ses = ses; + _logger = logger; + } + + public async Task SendOtpAsync(string toEmail, string code, TimeSpan validFor, + CancellationToken ct = default) + { + var minutes = (int)Math.Round(validFor.TotalMinutes); + + var text = $""" + Your gatool login code is: {code} + + This code is valid for {minutes} minutes. If you did not request this code, + you can safely ignore this email. + + — gatool + """; + + var html = $""" + + +

Your gatool login code

+

Use this code to finish signing in:

+

{code}

+

This code is valid for {minutes} minutes. If you didn't request it, you can ignore this message.

+

— gatool

+ + """; + + try + { + await _ses.SendEmailAsync(new SendEmailRequest + { + FromEmailAddress = FromAddress, + Destination = new Destination { ToAddresses = [toEmail] }, + Content = new EmailContent + { + Simple = new Message + { + Subject = new Content { Data = Subject, Charset = "UTF-8" }, + Body = new Body + { + Text = new Content { Data = text, Charset = "UTF-8" }, + Html = new Content { Data = html, Charset = "UTF-8" } + } + } + } + }, ct); + _logger.LogInformation("Sent OTP email to {Email}", toEmail); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send OTP email to {Email}", toEmail); + throw; + } + } +} diff --git a/Services/Auth/AuthRepository.cs b/Services/Auth/AuthRepository.cs new file mode 100644 index 0000000..6759b4d --- /dev/null +++ b/Services/Auth/AuthRepository.cs @@ -0,0 +1,529 @@ +using System.Text.Json; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using GAToolAPI.Models; + +namespace GAToolAPI.Services.Auth; + +/// +/// DynamoDB access layer for the gatool-auth single-table design. +/// +/// PK / SK patterns: +/// USER#{email} / PROFILE — UserRecord +/// USER#{email} / PASSKEY#{credentialId} — PasskeyRecord +/// OTP#{email} / CODE — OtpRecord (TTL ~10 min) +/// REFRESH#{tokenHash} / TOKEN — RefreshTokenRecord (TTL ~30 days) +/// PASSKEY-LOOKUP / {credentialId} — credentialId -> email index row +/// +/// TTL attribute name is "expiresAt" (epoch seconds). +/// +public class AuthRepository +{ + private const string TableName = "gatool-auth"; + private const string TtlAttribute = "expiresAt"; + + private readonly IAmazonDynamoDB _ddb; + private readonly ILogger _logger; + + public AuthRepository(IAmazonDynamoDB ddb, ILogger logger) + { + _ddb = ddb; + _logger = logger; + } + + private static string NormalizeEmail(string email) => email.Trim().ToLowerInvariant(); + + // ── Users ──────────────────────────────────────────────────────────────── + + public async Task GetUserAsync(string email, CancellationToken ct = default) + { + var resp = await _ddb.GetItemAsync(new GetItemRequest + { + TableName = TableName, + Key = Pk($"USER#{NormalizeEmail(email)}", "PROFILE"), + ConsistentRead = true + }, ct); + + return resp.Item is { Count: > 0 } ? UserFromItem(resp.Item) : null; + } + + public async Task UpsertUserAsync(string email, string[]? rolesIfNew = null, + CancellationToken ct = default) + { + var normalized = NormalizeEmail(email); + var existing = await GetUserAsync(normalized, ct); + if (existing != null) return existing; + + var record = new UserRecord + { + Email = normalized, + Roles = rolesIfNew ?? ["user"], + CreatedAt = DateTimeOffset.UtcNow + }; + + try + { + await _ddb.PutItemAsync(new PutItemRequest + { + TableName = TableName, + Item = UserToItem(record), + ConditionExpression = "attribute_not_exists(PK)" + }, ct); + _logger.LogInformation("Created auth user record for {Email}", normalized); + } + catch (ConditionalCheckFailedException) + { + // Race: another caller created it concurrently. Re-read. + return await GetUserAsync(normalized, ct) ?? record; + } + return record; + } + + public async Task SetRolesAsync(string email, string[] roles, CancellationToken ct = default) + { + var normalized = NormalizeEmail(email); + + // Ensure the record exists first + await UpsertUserAsync(normalized, roles, ct); + + var rolesAttr = new AttributeValue { L = roles.Select(r => new AttributeValue { S = r }).ToList() }; + await _ddb.UpdateItemAsync(new UpdateItemRequest + { + TableName = TableName, + Key = Pk($"USER#{normalized}", "PROFILE"), + UpdateExpression = "SET #r = :r", + ExpressionAttributeNames = new Dictionary { ["#r"] = "roles" }, + ExpressionAttributeValues = new Dictionary { [":r"] = rolesAttr } + }, ct); + } + + public async Task TouchLoginAsync(string email, CancellationToken ct = default) + { + var normalized = NormalizeEmail(email); + await _ddb.UpdateItemAsync(new UpdateItemRequest + { + TableName = TableName, + Key = Pk($"USER#{normalized}", "PROFILE"), + UpdateExpression = "SET lastLoginAt = :t", + ExpressionAttributeValues = new Dictionary + { + [":t"] = new() { S = DateTimeOffset.UtcNow.ToString("O") } + } + }, ct); + } + + public async Task DeleteUserAsync(string email, CancellationToken ct = default) + { + var normalized = NormalizeEmail(email); + + var passkeys = await ListPasskeysAsync(normalized, ct); + + // Delete profile + each passkey + each lookup row. + // BatchWriteItem caps at 25 per call. + var deletes = new List + { + new() { DeleteRequest = new DeleteRequest { Key = Pk($"USER#{normalized}", "PROFILE") } } + }; + foreach (var p in passkeys) + { + deletes.Add(new WriteRequest + { + DeleteRequest = new DeleteRequest { Key = Pk($"USER#{normalized}", $"PASSKEY#{p.CredentialId}") } + }); + deletes.Add(new WriteRequest + { + DeleteRequest = new DeleteRequest { Key = Pk("PASSKEY-LOOKUP", p.CredentialId) } + }); + } + + foreach (var chunk in deletes.Chunk(25)) + { + await _ddb.BatchWriteItemAsync(new BatchWriteItemRequest + { + RequestItems = new Dictionary> { [TableName] = chunk.ToList() } + }, ct); + } + + _logger.LogInformation("Deleted auth user record (and {Count} passkeys) for {Email}", + passkeys.Count, normalized); + } + + // ── Passkeys ──────────────────────────────────────────────────────────── + + public async Task> ListPasskeysAsync(string email, CancellationToken ct = default) + { + var normalized = NormalizeEmail(email); + var resp = await _ddb.QueryAsync(new QueryRequest + { + TableName = TableName, + KeyConditionExpression = "PK = :pk AND begins_with(SK, :sk)", + ExpressionAttributeValues = new Dictionary + { + [":pk"] = new() { S = $"USER#{normalized}" }, + [":sk"] = new() { S = "PASSKEY#" } + } + }, ct); + + return resp.Items.Select(PasskeyFromItem).ToList(); + } + + /// + /// Look up a passkey by credentialId across all users (used during authentication + /// when the browser sends a credentialId before the user has identified themselves). + /// Uses a denormalized PASSKEY-LOOKUP row. + /// + public async Task GetPasskeyByCredentialIdAsync(string credentialId, + CancellationToken ct = default) + { + var lookup = await _ddb.GetItemAsync(new GetItemRequest + { + TableName = TableName, + Key = Pk("PASSKEY-LOOKUP", credentialId), + ConsistentRead = true + }, ct); + if (lookup.Item is not { Count: > 0 } || !lookup.Item.TryGetValue("email", out var e)) + return null; + + var passkey = await _ddb.GetItemAsync(new GetItemRequest + { + TableName = TableName, + Key = Pk($"USER#{e.S}", $"PASSKEY#{credentialId}"), + ConsistentRead = true + }, ct); + return passkey.Item is { Count: > 0 } ? PasskeyFromItem(passkey.Item) : null; + } + + public async Task SavePasskeyAsync(PasskeyRecord passkey, CancellationToken ct = default) + { + passkey.Email = NormalizeEmail(passkey.Email); + + await _ddb.TransactWriteItemsAsync(new TransactWriteItemsRequest + { + TransactItems = + [ + new TransactWriteItem + { + Put = new Put + { + TableName = TableName, + Item = PasskeyToItem(passkey), + // Reject if this credentialId is already registered for this user + ConditionExpression = "attribute_not_exists(PK)" + } + }, + new TransactWriteItem + { + Put = new Put + { + TableName = TableName, + Item = new Dictionary + { + ["PK"] = new() { S = "PASSKEY-LOOKUP" }, + ["SK"] = new() { S = passkey.CredentialId }, + ["email"] = new() { S = passkey.Email } + }, + // Reject if a different user already claimed this credentialId + ConditionExpression = "attribute_not_exists(PK)" + } + } + ] + }, ct); + } + + public async Task UpdatePasskeyCounterAsync(string email, string credentialId, uint signCount, + CancellationToken ct = default) + { + var normalized = NormalizeEmail(email); + await _ddb.UpdateItemAsync(new UpdateItemRequest + { + TableName = TableName, + Key = Pk($"USER#{normalized}", $"PASSKEY#{credentialId}"), + UpdateExpression = "SET signCount = :c, lastUsedAt = :t", + ExpressionAttributeValues = new Dictionary + { + [":c"] = new() { N = signCount.ToString() }, + [":t"] = new() { S = DateTimeOffset.UtcNow.ToString("O") } + } + }, ct); + } + + public async Task DeletePasskeyAsync(string email, string credentialId, CancellationToken ct = default) + { + var normalized = NormalizeEmail(email); + await _ddb.TransactWriteItemsAsync(new TransactWriteItemsRequest + { + TransactItems = + [ + new TransactWriteItem + { + Delete = new Delete + { + TableName = TableName, + Key = Pk($"USER#{normalized}", $"PASSKEY#{credentialId}") + } + }, + new TransactWriteItem + { + Delete = new Delete + { + TableName = TableName, + Key = Pk("PASSKEY-LOOKUP", credentialId) + } + } + ] + }, ct); + } + + // ── OTP codes ─────────────────────────────────────────────────────────── + + public async Task SaveOtpAsync(OtpRecord record, CancellationToken ct = default) + { + record.Email = NormalizeEmail(record.Email); + var item = new Dictionary + { + ["PK"] = new() { S = $"OTP#{record.Email}" }, + ["SK"] = new() { S = "CODE" }, + ["codeHash"] = new() { S = record.CodeHash }, + ["createdAt"] = new() { S = record.CreatedAt.ToString("O") }, + ["attemptsRemaining"] = new() { N = record.AttemptsRemaining.ToString() }, + [TtlAttribute] = new() { N = record.ExpiresAt.ToUnixTimeSeconds().ToString() } + }; + await _ddb.PutItemAsync(new PutItemRequest { TableName = TableName, Item = item }, ct); + } + + public async Task GetOtpAsync(string email, CancellationToken ct = default) + { + var normalized = NormalizeEmail(email); + var resp = await _ddb.GetItemAsync(new GetItemRequest + { + TableName = TableName, + Key = Pk($"OTP#{normalized}", "CODE"), + ConsistentRead = true + }, ct); + if (resp.Item is not { Count: > 0 }) return null; + + var record = new OtpRecord + { + Email = normalized, + CodeHash = resp.Item.GetValueOrDefault("codeHash")?.S ?? "", + CreatedAt = ParseDate(resp.Item.GetValueOrDefault("createdAt")?.S), + AttemptsRemaining = int.Parse(resp.Item.GetValueOrDefault("attemptsRemaining")?.N ?? "0"), + ExpiresAt = DateTimeOffset.FromUnixTimeSeconds( + long.Parse(resp.Item.GetValueOrDefault(TtlAttribute)?.N ?? "0")) + }; + // Defensive: TTL only deletes within ~48 hours, so we double-check expiry on read. + if (record.ExpiresAt < DateTimeOffset.UtcNow) return null; + return record; + } + + public async Task DecrementOtpAttemptsAsync(string email, CancellationToken ct = default) + { + var normalized = NormalizeEmail(email); + try + { + await _ddb.UpdateItemAsync(new UpdateItemRequest + { + TableName = TableName, + Key = Pk($"OTP#{normalized}", "CODE"), + UpdateExpression = "SET attemptsRemaining = attemptsRemaining - :one", + ConditionExpression = "attemptsRemaining > :zero", + ExpressionAttributeValues = new Dictionary + { + [":one"] = new() { N = "1" }, + [":zero"] = new() { N = "0" } + } + }, ct); + } + catch (ConditionalCheckFailedException) + { + // Already at 0 — delete it + await DeleteOtpAsync(normalized, ct); + } + } + + public async Task DeleteOtpAsync(string email, CancellationToken ct = default) + { + var normalized = NormalizeEmail(email); + await _ddb.DeleteItemAsync(new DeleteItemRequest + { + TableName = TableName, + Key = Pk($"OTP#{normalized}", "CODE") + }, ct); + } + + // ── Refresh tokens ────────────────────────────────────────────────────── + + public async Task SaveRefreshTokenAsync(RefreshTokenRecord token, CancellationToken ct = default) + { + token.Email = NormalizeEmail(token.Email); + var item = new Dictionary + { + ["PK"] = new() { S = $"REFRESH#{token.TokenHash}" }, + ["SK"] = new() { S = "TOKEN" }, + ["email"] = new() { S = token.Email }, + ["createdAt"] = new() { S = token.CreatedAt.ToString("O") }, + [TtlAttribute] = new() { N = token.ExpiresAt.ToUnixTimeSeconds().ToString() } + }; + if (!string.IsNullOrEmpty(token.UserAgent)) + item["userAgent"] = new AttributeValue { S = token.UserAgent }; + + await _ddb.PutItemAsync(new PutItemRequest { TableName = TableName, Item = item }, ct); + } + + public async Task GetRefreshTokenAsync(string tokenHash, CancellationToken ct = default) + { + var resp = await _ddb.GetItemAsync(new GetItemRequest + { + TableName = TableName, + Key = Pk($"REFRESH#{tokenHash}", "TOKEN"), + ConsistentRead = true + }, ct); + if (resp.Item is not { Count: > 0 }) return null; + + var record = new RefreshTokenRecord + { + TokenHash = tokenHash, + Email = resp.Item.GetValueOrDefault("email")?.S ?? "", + CreatedAt = ParseDate(resp.Item.GetValueOrDefault("createdAt")?.S), + UserAgent = resp.Item.GetValueOrDefault("userAgent")?.S, + ExpiresAt = DateTimeOffset.FromUnixTimeSeconds( + long.Parse(resp.Item.GetValueOrDefault(TtlAttribute)?.N ?? "0")) + }; + if (record.ExpiresAt < DateTimeOffset.UtcNow) return null; + return record; + } + + public async Task DeleteRefreshTokenAsync(string tokenHash, CancellationToken ct = default) + { + await _ddb.DeleteItemAsync(new DeleteItemRequest + { + TableName = TableName, + Key = Pk($"REFRESH#{tokenHash}", "TOKEN") + }, ct); + } + + /// + /// Atomically consume a refresh token (delete it only if it still exists). + /// Returns true on the first successful consumption, false if the token was already + /// deleted by another caller (replay or concurrent refresh). + /// + public async Task TryConsumeRefreshTokenAsync(string tokenHash, CancellationToken ct = default) + { + try + { + await _ddb.DeleteItemAsync(new DeleteItemRequest + { + TableName = TableName, + Key = Pk($"REFRESH#{tokenHash}", "TOKEN"), + ConditionExpression = "attribute_exists(PK)" + }, ct); + return true; + } + catch (ConditionalCheckFailedException) + { + return false; + } + } + + /// + /// Atomically consume an OTP record only if the supplied codeHash still matches. + /// Used after a successful in-memory hash comparison to prevent the same code from + /// being redeemed twice under concurrent verification attempts. + /// + public async Task TryConsumeOtpAsync(string email, string expectedCodeHash, + CancellationToken ct = default) + { + var normalized = NormalizeEmail(email); + try + { + await _ddb.DeleteItemAsync(new DeleteItemRequest + { + TableName = TableName, + Key = Pk($"OTP#{normalized}", "CODE"), + ConditionExpression = "codeHash = :h", + ExpressionAttributeValues = new Dictionary + { + [":h"] = new() { S = expectedCodeHash } + } + }, ct); + return true; + } + catch (ConditionalCheckFailedException) + { + return false; + } + } + + // ── Item <-> POCO mappers ─────────────────────────────────────────────── + + private static Dictionary UserToItem(UserRecord r) => new() + { + ["PK"] = new() { S = $"USER#{r.Email}" }, + ["SK"] = new() { S = "PROFILE" }, + ["email"] = new() { S = r.Email }, + ["roles"] = new() { L = r.Roles.Select(role => new AttributeValue { S = role }).ToList() }, + ["createdAt"] = new() { S = r.CreatedAt.ToString("O") } + }; + + private static UserRecord UserFromItem(Dictionary item) => new() + { + Email = item.GetValueOrDefault("email")?.S ?? "", + Roles = item.GetValueOrDefault("roles")?.L?.Select(av => av.S).ToArray() ?? ["user"], + CreatedAt = ParseDate(item.GetValueOrDefault("createdAt")?.S), + LastLoginAt = item.TryGetValue("lastLoginAt", out var ll) && ll.S != null ? ParseDate(ll.S) : null + }; + + private static Dictionary PasskeyToItem(PasskeyRecord r) + { + var item = new Dictionary + { + ["PK"] = new() { S = $"USER#{r.Email}" }, + ["SK"] = new() { S = $"PASSKEY#{r.CredentialId}" }, + ["email"] = new() { S = r.Email }, + ["credentialId"] = new() { S = r.CredentialId }, + ["publicKey"] = new() { B = new MemoryStream(r.PublicKey) }, + ["signCount"] = new() { N = r.SignCount.ToString() }, + ["aaGuid"] = new() { S = r.AaGuid.ToString() }, + ["createdAt"] = new() { S = r.CreatedAt.ToString("O") } + }; + if (r.Transports.Length > 0) + item["transports"] = new AttributeValue + { + L = r.Transports.Select(t => new AttributeValue { S = t }).ToList() + }; + if (!string.IsNullOrEmpty(r.Nickname)) + item["nickname"] = new AttributeValue { S = r.Nickname }; + if (r.LastUsedAt.HasValue) + item["lastUsedAt"] = new AttributeValue { S = r.LastUsedAt.Value.ToString("O") }; + return item; + } + + private static PasskeyRecord PasskeyFromItem(Dictionary item) + { + var pkBytes = item.TryGetValue("publicKey", out var pkAv) && pkAv.B != null + ? pkAv.B.ToArray() + : []; + return new PasskeyRecord + { + Email = item.GetValueOrDefault("email")?.S ?? "", + CredentialId = item.GetValueOrDefault("credentialId")?.S ?? "", + PublicKey = pkBytes, + SignCount = uint.Parse(item.GetValueOrDefault("signCount")?.N ?? "0"), + AaGuid = Guid.TryParse(item.GetValueOrDefault("aaGuid")?.S, out var g) ? g : Guid.Empty, + Nickname = item.GetValueOrDefault("nickname")?.S, + CreatedAt = ParseDate(item.GetValueOrDefault("createdAt")?.S), + LastUsedAt = item.TryGetValue("lastUsedAt", out var lu) && lu.S != null ? ParseDate(lu.S) : null, + Transports = item.GetValueOrDefault("transports")?.L? + .Where(av => av.S != null).Select(av => av.S).ToArray() ?? [] + }; + } + + private static Dictionary Pk(string pk, string sk) => new() + { + ["PK"] = new() { S = pk }, + ["SK"] = new() { S = sk } + }; + + private static DateTimeOffset ParseDate(string? s) => + DateTimeOffset.TryParse(s, out var d) ? d : DateTimeOffset.MinValue; +} diff --git a/Services/Auth/AuthSigningKeyProvider.cs b/Services/Auth/AuthSigningKeyProvider.cs new file mode 100644 index 0000000..b414fa8 --- /dev/null +++ b/Services/Auth/AuthSigningKeyProvider.cs @@ -0,0 +1,86 @@ +using System.Security.Cryptography; +using Amazon.SecretsManager; +using Amazon.SecretsManager.Model; + +namespace GAToolAPI.Services.Auth; + +/// +/// Loads (and lazily creates) the ECDSA P-256 key used to sign self-issued JWTs. +/// The PEM-encoded private key is stored in Secrets Manager under "AuthSigningKey". +/// If the secret does not exist on first startup, a key is generated and persisted. +/// +public class AuthSigningKeyProvider +{ + private const string SecretName = "AuthSigningKey"; + + private readonly IAmazonSecretsManager _sm; + private readonly ILogger _logger; + private ECDsa? _key; + private readonly SemaphoreSlim _initLock = new(1, 1); + + public AuthSigningKeyProvider(IAmazonSecretsManager sm, ILogger logger) + { + _sm = sm; + _logger = logger; + } + + public async Task GetKeyAsync(CancellationToken ct = default) + { + if (_key != null) return _key; + await _initLock.WaitAsync(ct); + try + { + if (_key != null) return _key; + + string pem; + try + { + var resp = await _sm.GetSecretValueAsync( + new GetSecretValueRequest { SecretId = SecretName }, ct); + pem = resp.SecretString; + _logger.LogInformation("Loaded auth signing key from Secrets Manager"); + } + catch (ResourceNotFoundException) + { + _logger.LogWarning("Auth signing key not found in Secrets Manager — generating a new one"); + pem = GenerateAndStoreKeyAsync(ct).GetAwaiter().GetResult(); + } + + var ec = ECDsa.Create(); + ec.ImportFromPem(pem); + _key = ec; + return _key; + } + finally + { + _initLock.Release(); + } + } + + private async Task GenerateAndStoreKeyAsync(CancellationToken ct) + { + using var ec = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var pem = ec.ExportPkcs8PrivateKeyPem(); + try + { + await _sm.CreateSecretAsync(new CreateSecretRequest + { + Name = SecretName, + Description = "ECDSA P-256 private key for signing gatool API access tokens", + SecretString = pem + }, ct); + _logger.LogInformation("Stored newly-generated auth signing key in Secrets Manager"); + return pem; + } + catch (ResourceExistsException) + { + // Another pod beat us to creating the key — fetch and use theirs so all + // pods sign with the same key (otherwise tokens issued by one pod won't + // validate on another). + _logger.LogInformation("Auth signing key was created concurrently by another pod; reusing it"); + var resp = await _sm.GetSecretValueAsync( + new GetSecretValueRequest { SecretId = SecretName }, ct); + return resp.SecretString; + } + } +} diff --git a/Services/Auth/OtpPepperProvider.cs b/Services/Auth/OtpPepperProvider.cs new file mode 100644 index 0000000..27f3a43 --- /dev/null +++ b/Services/Auth/OtpPepperProvider.cs @@ -0,0 +1,83 @@ +using System.Security.Cryptography; +using Amazon.SecretsManager; +using Amazon.SecretsManager.Model; + +namespace GAToolAPI.Services.Auth; + +/// +/// Provides a server-side pepper used to HMAC-hash OTP codes before storage. +/// Without the pepper, a 6-digit OTP could be brute-forced trivially from the +/// stored hash; with it, the attacker also needs Secrets Manager read access. +/// +/// Lazily creates a 32-byte random pepper on first startup and stores it under +/// the secret name "AuthOtpPepper". +/// +public class OtpPepperProvider +{ + private const string SecretName = "AuthOtpPepper"; + + private readonly IAmazonSecretsManager _sm; + private readonly ILogger _logger; + private readonly SemaphoreSlim _initLock = new(1, 1); + private byte[]? _pepper; + + public OtpPepperProvider(IAmazonSecretsManager sm, ILogger logger) + { + _sm = sm; + _logger = logger; + } + + public async Task GetAsync(CancellationToken ct = default) + { + if (_pepper != null) return _pepper; + await _initLock.WaitAsync(ct); + try + { + if (_pepper != null) return _pepper; + + string b64; + try + { + var resp = await _sm.GetSecretValueAsync( + new GetSecretValueRequest { SecretId = SecretName }, ct); + b64 = resp.SecretString; + } + catch (ResourceNotFoundException) + { + _logger.LogWarning("OTP pepper not found in Secrets Manager — generating a new one"); + b64 = await GenerateAndStoreAsync(ct); + } + + _pepper = Convert.FromBase64String(b64); + return _pepper; + } + finally + { + _initLock.Release(); + } + } + + private async Task GenerateAndStoreAsync(CancellationToken ct) + { + var bytes = RandomNumberGenerator.GetBytes(32); + var b64 = Convert.ToBase64String(bytes); + try + { + await _sm.CreateSecretAsync(new CreateSecretRequest + { + Name = SecretName, + Description = "Server-side pepper for HMAC-SHA256 of OTP login codes", + SecretString = b64 + }, ct); + _logger.LogInformation("Stored newly-generated OTP pepper in Secrets Manager"); + return b64; + } + catch (ResourceExistsException) + { + // Another pod beat us to it — read what they wrote. + var resp = await _sm.GetSecretValueAsync( + new GetSecretValueRequest { SecretId = SecretName }, ct); + return resp.SecretString; + } + } +} diff --git a/Services/Auth/OtpService.cs b/Services/Auth/OtpService.cs new file mode 100644 index 0000000..2a119b0 --- /dev/null +++ b/Services/Auth/OtpService.cs @@ -0,0 +1,126 @@ +using System.Security.Cryptography; +using GAToolAPI.Models; + +namespace GAToolAPI.Services.Auth; + +/// +/// Generates and verifies one-time email login codes. +/// +/// - Code: 6-digit numeric (uniformly random) +/// - TTL: 10 minutes +/// - Verification attempts: 5 (then code is destroyed) +/// - Generation rate limit: 3 codes per email per 5 minutes (Redis fixed-window, fleet-wide) +/// - Stored hash: HMAC-SHA256(pepper, code) — pepper is in Secrets Manager, +/// so DynamoDB read access alone cannot brute-force a 6-digit code offline. +/// +public class OtpService +{ + public static readonly TimeSpan OtpLifetime = TimeSpan.FromMinutes(10); + public const int MaxVerifyAttempts = 5; + private const int CodeLength = 6; + private const int IssueLimitPerWindow = 3; + private static readonly TimeSpan IssueWindow = TimeSpan.FromMinutes(5); + + private readonly AuthRepository _repo; + private readonly AuthEmailService _email; + private readonly OtpPepperProvider _pepper; + private readonly RedisRateLimiter _rateLimiter; + private readonly ILogger _logger; + + public OtpService(AuthRepository repo, AuthEmailService email, + OtpPepperProvider pepper, RedisRateLimiter rateLimiter, ILogger logger) + { + _repo = repo; + _email = email; + _pepper = pepper; + _rateLimiter = rateLimiter; + _logger = logger; + } + + public enum IssueResult { Sent, RateLimited, EmailFailed } + + public async Task IssueAsync(string email, CancellationToken ct = default) + { + var normalized = email.Trim().ToLowerInvariant(); + if (!await _rateLimiter.TryAcquireAsync("otp-issue", normalized, IssueLimitPerWindow, IssueWindow)) + { + _logger.LogInformation("Rate limited OTP request for {Email}", normalized); + return IssueResult.RateLimited; + } + + var code = GenerateCode(); + var record = new OtpRecord + { + Email = normalized, + CodeHash = await HashAsync(code, ct), + CreatedAt = DateTimeOffset.UtcNow, + ExpiresAt = DateTimeOffset.UtcNow.Add(OtpLifetime), + AttemptsRemaining = MaxVerifyAttempts + }; + await _repo.SaveOtpAsync(record, ct); + + try + { + await _email.SendOtpAsync(normalized, code, OtpLifetime, ct); + return IssueResult.Sent; + } + catch + { + // Email failed — clean up the unsendable code so the user isn't locked out + await _repo.DeleteOtpAsync(normalized, ct); + return IssueResult.EmailFailed; + } + } + + public enum VerifyResult { Ok, NotFound, Expired, InvalidCode, NoAttemptsLeft } + + /// + /// Verify a submitted code. On success the OTP is atomically consumed (deleted under + /// a condition that the codeHash still matches) so concurrent verifications can't + /// double-redeem. On failure attempts is decremented; when attempts hit 0 the code + /// is destroyed. + /// + public async Task VerifyAsync(string email, string code, CancellationToken ct = default) + { + var normalized = email.Trim().ToLowerInvariant(); + var record = await _repo.GetOtpAsync(normalized, ct); + if (record == null) return VerifyResult.NotFound; + if (record.ExpiresAt < DateTimeOffset.UtcNow) return VerifyResult.Expired; + if (record.AttemptsRemaining <= 0) + { + await _repo.DeleteOtpAsync(normalized, ct); + return VerifyResult.NoAttemptsLeft; + } + + var submittedHash = await HashAsync(code.Trim(), ct); + // Constant-time comparison + if (!CryptographicOperations.FixedTimeEquals( + System.Text.Encoding.ASCII.GetBytes(submittedHash), + System.Text.Encoding.ASCII.GetBytes(record.CodeHash))) + { + await _repo.DecrementOtpAttemptsAsync(normalized, ct); + return VerifyResult.InvalidCode; + } + + // Conditional delete: only consume if the code we hashed matches what's still stored. + // Prevents a concurrent successful verification from double-spending the same code. + if (!await _repo.TryConsumeOtpAsync(normalized, record.CodeHash, ct)) + return VerifyResult.NotFound; + + return VerifyResult.Ok; + } + + private async Task HashAsync(string code, CancellationToken ct) + { + var pepper = await _pepper.GetAsync(ct); + var hash = HMACSHA256.HashData(pepper, System.Text.Encoding.UTF8.GetBytes(code)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static string GenerateCode() + { + // Uniformly distributed 6-digit code. RandomNumberGenerator.GetInt32 is unbiased. + var n = RandomNumberGenerator.GetInt32(0, 1_000_000); + return n.ToString("D" + CodeLength); + } +} diff --git a/Services/Auth/PasskeyService.cs b/Services/Auth/PasskeyService.cs new file mode 100644 index 0000000..fed83da --- /dev/null +++ b/Services/Auth/PasskeyService.cs @@ -0,0 +1,232 @@ +using System.Text.Json; +using Fido2NetLib; +using Fido2NetLib.Objects; +using GAToolAPI.Models; +using Microsoft.IdentityModel.Tokens; +using ZiggyCreatures.Caching.Fusion; + +namespace GAToolAPI.Services.Auth; + +/// +/// WebAuthn (passkey) registration and authentication on the server side. +/// Uses fido2-net-lib for protocol details. +/// +/// Challenges are held briefly in FusionCache (Redis-backed) keyed by an opaque session +/// id returned to the client. The client echoes that id back when completing the ceremony. +/// +public class PasskeyService +{ + private const string CachePrefix = "webauthn:challenge:"; + private static readonly TimeSpan ChallengeLifetime = TimeSpan.FromMinutes(5); + + private readonly IFido2 _fido2; + private readonly AuthRepository _repo; + private readonly IFusionCache _cache; + private readonly ILogger _logger; + + public PasskeyService(IFido2 fido2, AuthRepository repo, IFusionCache cache, + ILogger logger) + { + _fido2 = fido2; + _repo = repo; + _cache = cache; + _logger = logger; + } + + // ── Registration ──────────────────────────────────────────────────────── + + public record RegisterOptionsResult(string SessionId, CredentialCreateOptions Options); + + public async Task BeginRegistrationAsync(string email, CancellationToken ct = default) + { + var normalized = email.Trim().ToLowerInvariant(); + var existing = await _repo.ListPasskeysAsync(normalized, ct); + var exclude = existing.Select(p => new PublicKeyCredentialDescriptor(Base64UrlEncoder.DecodeBytes(p.CredentialId))) + .ToList(); + + var user = new Fido2User + { + DisplayName = normalized, + Name = normalized, + Id = System.Text.Encoding.UTF8.GetBytes(normalized) + }; + + var authenticatorSelection = new AuthenticatorSelection + { + // Discoverable credentials so users can sign in without typing their email + RequireResidentKey = true, + // Required: passkeys replace passwords/OTP, so we always want UV (biometric/PIN) + UserVerification = UserVerificationRequirement.Required + }; + + var options = _fido2.RequestNewCredential( + user, + exclude, + authenticatorSelection, + AttestationConveyancePreference.None, + new AuthenticationExtensionsClientInputs()); + + var sessionId = NewSessionId(); + await _cache.SetAsync(CachePrefix + sessionId, + new ChallengeBlob { Email = normalized, OptionsJson = options.ToJson() }, + ChallengeLifetime, token: ct); + + return new RegisterOptionsResult(sessionId, options); + } + + public async Task CompleteRegistrationAsync( + string email, + string sessionId, + AuthenticatorAttestationRawResponse attestation, + string? nickname, + CancellationToken ct = default) + { + var normalized = email.Trim().ToLowerInvariant(); + + var blob = await _cache.TryGetAsync(CachePrefix + sessionId, token: ct); + if (!blob.HasValue || blob.Value.Email != normalized) + { + _logger.LogWarning("Passkey registration session not found / mismatched email for {Email}", normalized); + return null; + } + await _cache.RemoveAsync(CachePrefix + sessionId, token: ct); + + var origOptions = CredentialCreateOptions.FromJson(blob.Value.OptionsJson); + + var result = await _fido2.MakeNewCredentialAsync( + attestation, + origOptions, + async (args, innerCt) => + { + var existing = await _repo.GetPasskeyByCredentialIdAsync( + Base64UrlEncoder.Encode(args.CredentialId), innerCt); + return existing == null; + }, + requestTokenBindingId: null, + cancellationToken: ct); + + var credentialIdB64 = Base64UrlEncoder.Encode(result.Result!.CredentialId); + var record = new PasskeyRecord + { + Email = normalized, + CredentialId = credentialIdB64, + PublicKey = result.Result.PublicKey, + SignCount = result.Result.Counter, + AaGuid = result.Result.Aaguid, + Nickname = nickname, + CreatedAt = DateTimeOffset.UtcNow, + // Transports aren't surfaced by Fido2 v3's AttestationVerificationSuccess. + // The browser may send them at registration time but we don't persist here; + // they'd primarily be used to populate allowCredentials during authentication. + Transports = [] + }; + await _repo.SavePasskeyAsync(record, ct); + _logger.LogInformation("Registered passkey {CredentialId} for {Email}", credentialIdB64, normalized); + return record; + } + + // ── Authentication ───────────────────────────────────────────────────── + + public record AuthOptionsResult(string SessionId, AssertionOptions Options); + + public async Task BeginAuthenticationAsync(string? email, CancellationToken ct = default) + { + // For username-less / discoverable credential flow, allowedCredentials is empty. + // For username-first flow, restrict to that user's credentials. + var allowed = new List(); + string? normalized = null; + if (!string.IsNullOrWhiteSpace(email)) + { + normalized = email.Trim().ToLowerInvariant(); + var creds = await _repo.ListPasskeysAsync(normalized, ct); + allowed.AddRange(creds.Select(c => new PublicKeyCredentialDescriptor(Base64UrlEncoder.DecodeBytes(c.CredentialId)))); + } + + var options = _fido2.GetAssertionOptions( + allowed, + UserVerificationRequirement.Required, + new AuthenticationExtensionsClientInputs()); + + var sessionId = NewSessionId(); + await _cache.SetAsync(CachePrefix + sessionId, + new ChallengeBlob { Email = normalized, OptionsJson = options.ToJson() }, + ChallengeLifetime, token: ct); + + return new AuthOptionsResult(sessionId, options); + } + + public async Task CompleteAuthenticationAsync( + string sessionId, + AuthenticatorAssertionRawResponse assertion, + CancellationToken ct = default) + { + var blob = await _cache.TryGetAsync(CachePrefix + sessionId, token: ct); + if (!blob.HasValue) + { + _logger.LogWarning("Passkey auth session not found"); + return null; + } + await _cache.RemoveAsync(CachePrefix + sessionId, token: ct); + + var origOptions = AssertionOptions.FromJson(blob.Value.OptionsJson); + + var credentialIdB64 = Base64UrlEncoder.Encode(assertion.Id); + var stored = await _repo.GetPasskeyByCredentialIdAsync(credentialIdB64, ct); + if (stored == null) + { + _logger.LogWarning("Passkey {CredentialId} not registered", credentialIdB64); + return null; + } + + // If the user supplied an email when starting the ceremony, enforce that the + // selected credential actually belongs to that user. This prevents the confusing + // case where "sign in as alice" ends up authenticating bob because the browser + // chose Bob's discoverable credential. + if (!string.IsNullOrEmpty(blob.Value.Email) && + !string.Equals(blob.Value.Email, stored.Email, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Passkey owner {Owner} does not match requested email {Requested}", + stored.Email, blob.Value.Email); + return null; + } + + var verifyResult = await _fido2.MakeAssertionAsync( + assertion, + origOptions, + stored.PublicKey, + stored.SignCount, + async (args, innerCt) => + { + // userHandle is the email bytes we set during registration + if (args.UserHandle == null) return true; // username-first flow has no userHandle + var handleEmail = System.Text.Encoding.UTF8.GetString(args.UserHandle); + return string.Equals(handleEmail, stored.Email, StringComparison.OrdinalIgnoreCase); + }, + requestTokenBindingId: null, + cancellationToken: ct); + + await _repo.UpdatePasskeyCounterAsync(stored.Email, credentialIdB64, verifyResult.Counter, ct); + + var user = await _repo.GetUserAsync(stored.Email, ct); + if (user == null) + { + _logger.LogWarning("Passkey {CredentialId} references missing user {Email}", + credentialIdB64, stored.Email); + return null; + } + return user; + } + + private static string NewSessionId() + { + Span bytes = stackalloc byte[24]; + System.Security.Cryptography.RandomNumberGenerator.Fill(bytes); + return Base64UrlEncoder.Encode(bytes.ToArray()); + } + + private class ChallengeBlob + { + public string? Email { get; set; } + public string OptionsJson { get; set; } = ""; + } +} diff --git a/Services/Auth/RedisRateLimiter.cs b/Services/Auth/RedisRateLimiter.cs new file mode 100644 index 0000000..c58f750 --- /dev/null +++ b/Services/Auth/RedisRateLimiter.cs @@ -0,0 +1,48 @@ +using StackExchange.Redis; + +namespace GAToolAPI.Services.Auth; + +/// +/// Distributed fixed-window rate limiter backed by Redis INCR + EXPIRE. +/// +/// Each (bucket, key) pair maps to a counter that starts at 0 and resets when its +/// TTL elapses. The first request in a window sets the TTL; subsequent requests +/// only increment. If Redis is unreachable we fail open and log a warning — the +/// caller's flow (e.g. issuing an OTP) is more important than a perfect rate cap. +/// +public class RedisRateLimiter +{ + private readonly IConnectionMultiplexer _redis; + private readonly ILogger _logger; + + public RedisRateLimiter(IConnectionMultiplexer redis, ILogger logger) + { + _redis = redis; + _logger = logger; + } + + /// + /// Returns true if the call is permitted, false if the limit has been exceeded. + /// + public async Task TryAcquireAsync(string bucket, string key, int limit, TimeSpan window) + { + var redisKey = $"ratelimit:{bucket}:{key}"; + try + { + var db = _redis.GetDatabase(); + var count = await db.StringIncrementAsync(redisKey); + if (count == 1) + { + // First hit in this window — set the TTL. Use KeyExpire so we don't + // race with a concurrent INCR resetting the value. + await db.KeyExpireAsync(redisKey, window, ExpireWhen.HasNoExpiry); + } + return count <= limit; + } + catch (RedisException ex) + { + _logger.LogWarning(ex, "Redis rate limiter unavailable for {Bucket}:{Key} — failing open", bucket, key); + return true; + } + } +} diff --git a/Services/Auth/TokenService.cs b/Services/Auth/TokenService.cs new file mode 100644 index 0000000..97cf829 --- /dev/null +++ b/Services/Auth/TokenService.cs @@ -0,0 +1,154 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using GAToolAPI.Models; +using Microsoft.IdentityModel.Tokens; + +namespace GAToolAPI.Services.Auth; + +/// +/// Issues and validates self-signed JWT access tokens, and manages opaque refresh tokens. +/// +/// Access tokens: +/// - Algorithm: ES256 (ECDSA P-256 + SHA-256) +/// - Issuer: https://api.gatool.org/auth +/// - Audience: gatool +/// - Lifetime: 15 minutes +/// - Claims: sub, email, name, https://gatool.org/roles (one per role), iat, exp, jti +/// +/// Refresh tokens: +/// - Opaque, 256-bit random URL-safe string +/// - Stored in DynamoDB as SHA-256 hash (never plaintext) +/// - Lifetime: 30 days, sliding (renewed on each refresh) +/// +public class TokenService +{ + public const string Issuer = "https://api.gatool.org/auth"; + public const string Audience = "gatool"; + public const string RolesClaim = "https://gatool.org/roles"; + public static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(15); + public static readonly TimeSpan RefreshTokenLifetime = TimeSpan.FromDays(30); + + private readonly AuthSigningKeyProvider _keyProvider; + private readonly AuthRepository _repo; + + public TokenService(AuthSigningKeyProvider keyProvider, AuthRepository repo) + { + _keyProvider = keyProvider; + _repo = repo; + } + + public async Task IssueTokensAsync(UserRecord user, string? userAgent = null, + CancellationToken ct = default) + { + var accessToken = await CreateAccessTokenAsync(user, ct); + var refresh = CreateRefreshToken(); + await _repo.SaveRefreshTokenAsync(new RefreshTokenRecord + { + TokenHash = Sha256Hex(refresh), + Email = user.Email, + CreatedAt = DateTimeOffset.UtcNow, + ExpiresAt = DateTimeOffset.UtcNow.Add(RefreshTokenLifetime), + UserAgent = userAgent + }, ct); + + return new TokenResponse( + accessToken, + refresh, + (int)AccessTokenLifetime.TotalSeconds, + user.Email, + user.Roles); + } + + /// + /// Validate a refresh token, atomically consume it, and issue a new access + refresh pair. + /// Returns null if the refresh token is unknown, expired, revoked, or was already + /// consumed by another concurrent request (replay protection). + /// + public async Task RefreshAsync(string refreshToken, string? userAgent = null, + CancellationToken ct = default) + { + var hash = Sha256Hex(refreshToken); + var record = await _repo.GetRefreshTokenAsync(hash, ct); + if (record == null) return null; + + // Atomically delete the old token. If two concurrent refreshes use the same token, + // only the first wins; the second sees a ConditionalCheckFailed and returns null. + // This is the core of refresh-token rotation as a replay-detection mechanism. + if (!await _repo.TryConsumeRefreshTokenAsync(hash, ct)) + return null; + + var user = await _repo.GetUserAsync(record.Email, ct); + if (user == null) return null; + + return await IssueTokensAsync(user, userAgent, ct); + } + + public async Task RevokeRefreshTokenAsync(string refreshToken, CancellationToken ct = default) + { + var hash = Sha256Hex(refreshToken); + await _repo.DeleteRefreshTokenAsync(hash, ct); + } + + public async Task GetValidationKeyAsync(CancellationToken ct = default) + { + var ec = await _keyProvider.GetKeyAsync(ct); + return new ECDsaSecurityKey(ec); + } + + public TokenValidationParameters BuildValidationParameters(SecurityKey key) => new() + { + ValidateIssuer = true, + ValidIssuer = Issuer, + ValidateAudience = true, + ValidAudience = Audience, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ClockSkew = TimeSpan.FromMinutes(1), + NameClaimType = "name", + RoleClaimType = RolesClaim + }; + + private async Task CreateAccessTokenAsync(UserRecord user, CancellationToken ct) + { + var ec = await _keyProvider.GetKeyAsync(ct); + var key = new ECDsaSecurityKey(ec); + var creds = new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256); + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, user.Email), + new(JwtRegisteredClaimNames.Email, user.Email), + new("name", user.Email), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")) + }; + // One claim per role with the same type so existing HasRoleHandler keeps working + foreach (var role in user.Roles) + claims.Add(new Claim(RolesClaim, role)); + + var now = DateTime.UtcNow; + var token = new JwtSecurityToken( + issuer: Issuer, + audience: Audience, + claims: claims, + notBefore: now, + expires: now.Add(AccessTokenLifetime), + signingCredentials: creds); + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private static string CreateRefreshToken() + { + Span bytes = stackalloc byte[32]; + RandomNumberGenerator.Fill(bytes); + return Base64UrlEncoder.Encode(bytes.ToArray()); + } + + public static string Sha256Hex(string input) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} diff --git a/Services/MailchimpWebhookService.cs b/Services/MailchimpWebhookService.cs index a68a3c8..ceb39dd 100644 --- a/Services/MailchimpWebhookService.cs +++ b/Services/MailchimpWebhookService.cs @@ -3,24 +3,48 @@ using Auth0.AuthenticationApi.Models; using Auth0.ManagementApi; using Auth0.ManagementApi.Models; +using GAToolAPI.Services.Auth; using MailChimp.Net; using MailChimp.Net.Core; namespace GAToolAPI.Services; +/// +/// Handles Mailchimp subscribe/unsubscribe/profile webhooks. +/// +/// During the Auth0 -> custom-auth migration we mirror role/user changes +/// to BOTH systems so existing Auth0-issued tokens (still in flight) and +/// the new gatool-issued tokens see consistent role state. Once every +/// client has cut over to the new auth flow, the Auth0 side can be +/// removed (along with the Auth0 packages and admin secrets) by the +/// `cleanup-auth0` task. +/// public class MailchimpWebhookService { private const string OptInText = "I want access to gatool and agree that I will not abuse this access to team data."; + // Auth0 (legacy, kept until cutover) private const string Auth0Domain = "gatool.auth0.com"; private const string FullUserRoleId = "rol_KRLODHx3eNItUgvI"; private const string ReadOnlyRoleId = "rol_EQcREtmOWaGanRYG"; + private const string WelcomeTag = "gatool-welcome"; private readonly ILogger _logger; private readonly ISecretProvider _secretProvider; private readonly UserStorageService _userStorageService; + private readonly AuthRepository _authRepository; + + private readonly SlidingWindowRateLimiter _mailchimpRateLimiter = new(new SlidingWindowRateLimiterOptions + { + AutoReplenishment = true, + Window = TimeSpan.FromSeconds(1), + PermitLimit = 5, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + SegmentsPerWindow = 1, + QueueLimit = int.MaxValue + }); private readonly SlidingWindowRateLimiter _auth0RateLimiter = new(new SlidingWindowRateLimiterOptions { @@ -32,7 +56,10 @@ public class MailchimpWebhookService QueueLimit = int.MaxValue }); - // Lazy-initialized clients (need secrets from async provider) + private MailChimpManager? _mailChimpClient; + private string? _mailChimpListId; + + // Auth0 management client — lazy-initialized & token-refreshed private ManagementApiClient? _auth0Client; private DateTimeOffset _auth0TokenExpiresAt = DateTimeOffset.MinValue; private string? _auth0ClientId; @@ -40,17 +67,16 @@ public class MailchimpWebhookService private readonly SemaphoreSlim _auth0TokenLock = new(1, 1); private static readonly TimeSpan Auth0TokenRefreshSkew = TimeSpan.FromMinutes(5); - private MailChimpManager? _mailChimpClient; - private string? _mailChimpListId; - public MailchimpWebhookService( ILogger logger, ISecretProvider secretProvider, - UserStorageService userStorageService) + UserStorageService userStorageService, + AuthRepository authRepository) { _logger = logger; _secretProvider = secretProvider; _userStorageService = userStorageService; + _authRepository = authRepository; } public async Task HandleEventAsync(string eventType, string email, string? gatoolMergeField, @@ -83,50 +109,94 @@ private async Task HandleSubscribeOrProfileAsync(string email, string? gatoolMer CancellationToken cancellationToken) { var isOptedIn = gatoolMergeField == OptInText; - var (user, created) = await GetOrCreateAuth0UserAsync(email, cancellationToken); - if (isOptedIn) + // === New auth (DynamoDB) === + // "Opted in" gets the full "user" role (read + write). + // "Not opted in" gets no roles (record exists but no API access — equivalent + // to the previous Auth0 read-only role, since all gated endpoints require "user"). + var roles = isOptedIn ? new[] { "user" } : Array.Empty(); + + var existing = await _authRepository.GetUserAsync(email, cancellationToken); + var newAuthCreated = existing == null; + if (newAuthCreated) { - _logger.LogInformation("Assigning full user role to {Email}", email); - await _auth0RateLimiter.AcquireAsync(cancellationToken: cancellationToken); - await _auth0Client!.Users.AssignRolesAsync(user.UserId, new AssignRolesRequest - { - Roles = [FullUserRoleId] - }, cancellationToken); + await _authRepository.UpsertUserAsync(email, roles, cancellationToken); + _logger.LogInformation("Created auth user for {Email} with roles=[{Roles}]", + email, string.Join(",", roles)); } else { - _logger.LogInformation("Assigning read-only role to {Email}", email); - await _auth0RateLimiter.AcquireAsync(cancellationToken: cancellationToken); - await _auth0Client!.Users.AssignRolesAsync(user.UserId, new AssignRolesRequest + await _authRepository.SetRolesAsync(email, roles, cancellationToken); + _logger.LogInformation("Updated roles for {Email} to [{Roles}]", email, string.Join(",", roles)); + } + + // === Legacy auth (Auth0) — mirror until cutover === + bool auth0Created = false; + try + { + var (user, created) = await GetOrCreateAuth0UserAsync(email, cancellationToken); + auth0Created = created; + if (isOptedIn) { - Roles = [ReadOnlyRoleId] - }, cancellationToken); - await _auth0RateLimiter.AcquireAsync(cancellationToken: cancellationToken); - await _auth0Client!.Users.RemoveRolesAsync(user.UserId, new AssignRolesRequest + _logger.LogInformation("Auth0: assigning full user role to {Email}", email); + await _auth0RateLimiter.AcquireAsync(cancellationToken: cancellationToken); + await _auth0Client!.Users.AssignRolesAsync(user.UserId, new AssignRolesRequest + { + Roles = [FullUserRoleId] + }, cancellationToken); + } + else { - Roles = [FullUserRoleId] - }, cancellationToken); + _logger.LogInformation("Auth0: assigning read-only role to {Email}", email); + await _auth0RateLimiter.AcquireAsync(cancellationToken: cancellationToken); + await _auth0Client!.Users.AssignRolesAsync(user.UserId, new AssignRolesRequest + { + Roles = [ReadOnlyRoleId] + }, cancellationToken); + await _auth0RateLimiter.AcquireAsync(cancellationToken: cancellationToken); + await _auth0Client!.Users.RemoveRolesAsync(user.UserId, new AssignRolesRequest + { + Roles = [FullUserRoleId] + }, cancellationToken); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Auth0 mirror failed for {Email} during subscribe/profile — continuing", email); } - if (created) + // Tag for welcome only the first time we see this user in EITHER system, + // so resubscribers don't get re-welcomed. + if (newAuthCreated || auth0Created) { - _logger.LogInformation("New user created for {Email}, tagging for welcome email", email); await TagSubscriberForWelcomeAsync(email, cancellationToken); } } private async Task HandleUnsubscribeAsync(string email, CancellationToken cancellationToken) { - await _auth0RateLimiter.AcquireAsync(cancellationToken: cancellationToken); - var users = (await _auth0Client!.Users.GetUsersByEmailAsync(email, cancellationToken: cancellationToken)) - .Where(u => u.Identities.Any(i => i.Provider == "email")).ToList(); + // New auth + await _authRepository.DeleteUserAsync(email, cancellationToken); + _logger.LogInformation("Deleted auth account for {Email}", email); - foreach (var user in users) + // Legacy auth (Auth0) — mirror until cutover + try { await _auth0RateLimiter.AcquireAsync(cancellationToken: cancellationToken); - await _auth0Client.Users.DeleteAsync(user.UserId); - _logger.LogInformation("Deleted Auth0 account for {Email}", email); + var users = (await _auth0Client!.Users.GetUsersByEmailAsync(email, + cancellationToken: cancellationToken)) + .Where(u => u.Identities.Any(i => i.Provider == "email")).ToList(); + + foreach (var user in users) + { + await _auth0RateLimiter.AcquireAsync(cancellationToken: cancellationToken); + await _auth0Client.Users.DeleteAsync(user.UserId); + _logger.LogInformation("Deleted Auth0 account for {Email}", email); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Auth0 mirror failed for {Email} during unsubscribe — continuing", email); } } @@ -154,6 +224,7 @@ private async Task TagSubscriberForWelcomeAsync(string email, CancellationToken { try { + await _mailchimpRateLimiter.AcquireAsync(cancellationToken: cancellationToken); using var md5 = System.Security.Cryptography.MD5.Create(); var subscriberHash = MailChimp.Net.Core.Helper.GetHash(md5, email.ToLowerInvariant()); await _mailChimpClient!.Members.AddTagsAsync(_mailChimpListId!, subscriberHash, diff --git a/appsettings.Development.json b/appsettings.Development.json index 5359560..7daee87 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -26,5 +26,9 @@ "WithThreadId" ] }, - "DryRun": true + "DryRun": true, + "WebAuthn": { + "ServerDomain": "localhost", + "Origins": "http://localhost:3000" + } } diff --git a/gatool-api.csproj b/gatool-api.csproj index 2a5333c..50589da 100644 --- a/gatool-api.csproj +++ b/gatool-api.csproj @@ -20,6 +20,8 @@ + + diff --git a/infra-cdk/GatoolStack.cs b/infra-cdk/GatoolStack.cs index f7b95b8..91be3a3 100644 --- a/infra-cdk/GatoolStack.cs +++ b/infra-cdk/GatoolStack.cs @@ -60,6 +60,27 @@ public GatoolStack(Construct scope, string id, IStackProps? props = null) : base RemovalPolicy = RemovalPolicy.RETAIN }); + // ── Auth Table (single-table design for users, OTPs, passkeys, refresh tokens) ── + // PK / SK patterns: + // USER#{email} / PROFILE — user record (roles, createdAt) + // USER#{email} / PASSKEY#{credentialId} — registered passkey (publicKey, counter, …) + // OTP#{email} / CODE#{hash} — one-time login code (TTL ~10 min) + // REFRESH#{tokenHash} / TOKEN — refresh token (TTL ~30 days) + // TTL on `expiresAt` (epoch seconds) prunes OTPs and refresh tokens automatically. + var authTable = new Table(this, "AuthTable", new TableProps + { + TableName = "gatool-auth", + PartitionKey = new Attribute { Name = "PK", Type = AttributeType.STRING }, + SortKey = new Attribute { Name = "SK", Type = AttributeType.STRING }, + BillingMode = BillingMode.PAY_PER_REQUEST, + TimeToLiveAttribute = "expiresAt", + RemovalPolicy = RemovalPolicy.RETAIN, + PointInTimeRecoverySpecification = new PointInTimeRecoverySpecification + { + PointInTimeRecoveryEnabled = true + } + }); + // ── ECS Cluster ───────────────────────────────────────────────── var cluster = new Cluster(this, "GatoolCluster", new ClusterProps { @@ -208,6 +229,15 @@ public GatoolStack(Construct scope, string id, IStackProps? props = null) : base // Grant DynamoDB access highScoresTable.GrantReadWriteData(taskDef.TaskRole); + authTable.GrantReadWriteData(taskDef.TaskRole); + + // Grant SES access (for sending OTP login codes from auth.gatool.org / gatool.org) + taskDef.TaskRole.AddToPrincipalPolicy(new PolicyStatement(new PolicyStatementProps + { + Effect = Effect.ALLOW, + Actions = ["ses:SendEmail", "ses:SendRawEmail"], + Resources = ["*"] + })); // Grant Secrets Manager access taskDef.TaskRole.AddManagedPolicy( @@ -310,6 +340,13 @@ public GatoolStack(Construct scope, string id, IStackProps? props = null) : base foreach (var bucket in buckets) bucket.GrantReadWrite(jobTaskDef.TaskRole); highScoresTable.GrantReadWriteData(jobTaskDef.TaskRole); + authTable.GrantReadWriteData(jobTaskDef.TaskRole); + jobTaskDef.TaskRole.AddToPrincipalPolicy(new PolicyStatement(new PolicyStatementProps + { + Effect = Effect.ALLOW, + Actions = ["ses:SendEmail", "ses:SendRawEmail"], + Resources = ["*"] + })); jobTaskDef.TaskRole.AddManagedPolicy( ManagedPolicy.FromAwsManagedPolicyName("SecretsManagerReadWrite"));