ASP.NET CORE MVC - Identity Bearer Tokens

ASP.NET CORE MVC TUTORIAL SERIES · PART 17A

Secure REST API Access with ASP.NET Core Identity Bearer Tokens

Continue from Part 12 and test the complete owner-protected REST API locally with curl.

Starting point

This tutorial continues from Part 12. Identity login and Product ownership must already work.

1. Open the Project

cd ~
cd aspnet-mvc-tutorial/ProductManagement
pwd
dotnet build
code .

Expected path:

/home/xubuntu/aspnet-mvc-tutorial/ProductManagement

2. Understand the Two Authentication Methods

Browser login → Identity cookie → MVC pages API login → Bearer token → curl → REST API

3. Update Program.cs

Open:

code Program.cs

Replace the old AddDefaultIdentity block with:

builder.Services
    .AddIdentityApiEndpoints<IdentityUser>(options =>
    {
        options.SignIn.RequireConfirmedAccount = false;
    })
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultUI()
    .AddDefaultTokenProviders();

Then add this after app.MapRazorPages();:

app.MapGroup("/api/auth")
    .MapIdentityApi<IdentityUser>();

4. Full Program.cs Check

using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Data;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();

builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlite(
        builder.Configuration.GetConnectionString(
            "DefaultConnection")));

builder.Services
    .AddIdentityApiEndpoints<IdentityUser>(options =>
    {
        options.SignIn.RequireConfirmedAccount = false;
    })
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultUI()
    .AddDefaultTokenProviders();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

app.MapRazorPages();

app.MapGroup("/api/auth")
    .MapIdentityApi<IdentityUser>();

app.Run();

5. Build the Identity Change

cd ~/aspnet-mvc-tutorial/ProductManagement
dotnet build

6. Create ProductApiViewModels.cs

mkdir -p ViewModels
touch ViewModels/ProductApiViewModels.cs
code ViewModels/ProductApiViewModels.cs

Paste:

using System.ComponentModel.DataAnnotations;

namespace ProductManagement.ViewModels;

public class ProductApiRequestViewModel
{
    [Required]
    [StringLength(100)]
    public string Name { get; set; } = string.Empty;

    [StringLength(500)]
    public string? Description { get; set; }

    [Range(0.01, 1000000)]
    public decimal Price { get; set; }

    [Range(0, 1000000)]
    public int Quantity { get; set; }

    public int? CategoryId { get; set; }
}

public class ProductApiResponseViewModel
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string? Description { get; set; }
    public decimal Price { get; set; }
    public int Quantity { get; set; }
    public int? CategoryId { get; set; }
    public string? CategoryName { get; set; }
}

7. Create ProductsApiController.cs

mkdir -p Controllers
touch Controllers/ProductsApiController.cs
code Controllers/ProductsApiController.cs

Paste:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Data;
using ProductManagement.Models;
using ProductManagement.ViewModels;

namespace ProductManagement.Controllers;

[ApiController]
[Authorize]
[Route("api/products")]
public class ProductsApiController : ControllerBase
{
    private readonly ApplicationDbContext _context;
    private readonly UserManager<IdentityUser> _userManager;

    public ProductsApiController(
        ApplicationDbContext context,
        UserManager<IdentityUser> userManager)
    {
        _context = context;
        _userManager = userManager;
    }

    [HttpGet]
    public async Task<ActionResult<IEnumerable<ProductApiResponseViewModel>>> GetAll()
    {
        var userId = _userManager.GetUserId(User);

        if (userId == null)
            return Unauthorized();

        var products = await _context.Products
            .Where(p => p.OwnerId == userId)
            .Include(p => p.Category)
            .OrderBy(p => p.Name)
            .Select(p => new ProductApiResponseViewModel
            {
                Id = p.Id,
                Name = p.Name,
                Description = p.Description,
                Price = p.Price,
                Quantity = p.Quantity,
                CategoryId = p.CategoryId,
                CategoryName = p.Category != null ? p.Category.Name : null
            })
            .ToListAsync();

        return Ok(products);
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<ProductApiResponseViewModel>> GetById(int id)
    {
        var userId = _userManager.GetUserId(User);

        if (userId == null)
            return Unauthorized();

        var product = await _context.Products
            .Where(p => p.Id == id && p.OwnerId == userId)
            .Include(p => p.Category)
            .Select(p => new ProductApiResponseViewModel
            {
                Id = p.Id,
                Name = p.Name,
                Description = p.Description,
                Price = p.Price,
                Quantity = p.Quantity,
                CategoryId = p.CategoryId,
                CategoryName = p.Category != null ? p.Category.Name : null
            })
            .FirstOrDefaultAsync();

        if (product == null)
            return NotFound();

        return Ok(product);
    }

    [HttpPost]
    public async Task<ActionResult<ProductApiResponseViewModel>> Create(
        ProductApiRequestViewModel request)
    {
        var userId = _userManager.GetUserId(User);

        if (userId == null)
            return Unauthorized();

        if (request.CategoryId.HasValue &&
            !await _context.Categories.AnyAsync(c => c.Id == request.CategoryId.Value))
        {
            return BadRequest(new { message = "Category does not exist." });
        }

        var product = new Product
        {
            Name = request.Name,
            Description = request.Description,
            Price = request.Price,
            Quantity = request.Quantity,
            CategoryId = request.CategoryId,
            OwnerId = userId
        };

        _context.Products.Add(product);
        await _context.SaveChangesAsync();

        return CreatedAtAction(
            nameof(GetById),
            new { id = product.Id },
            new ProductApiResponseViewModel
            {
                Id = product.Id,
                Name = product.Name,
                Description = product.Description,
                Price = product.Price,
                Quantity = product.Quantity,
                CategoryId = product.CategoryId
            });
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(
        int id,
        ProductApiRequestViewModel request)
    {
        var userId = _userManager.GetUserId(User);

        if (userId == null)
            return Unauthorized();

        var product = await _context.Products
            .FirstOrDefaultAsync(p => p.Id == id && p.OwnerId == userId);

        if (product == null)
            return NotFound();

        if (request.CategoryId.HasValue &&
            !await _context.Categories.AnyAsync(c => c.Id == request.CategoryId.Value))
        {
            return BadRequest(new { message = "Category does not exist." });
        }

        product.Name = request.Name;
        product.Description = request.Description;
        product.Price = request.Price;
        product.Quantity = request.Quantity;
        product.CategoryId = request.CategoryId;

        await _context.SaveChangesAsync();

        return NoContent();
    }

    [HttpDelete("{id:int}")]
    public async Task<IActionResult> Delete(int id)
    {
        var userId = _userManager.GetUserId(User);

        if (userId == null)
            return Unauthorized();

        var product = await _context.Products
            .FirstOrDefaultAsync(p => p.Id == id && p.OwnerId == userId);

        if (product == null)
            return NotFound();

        _context.Products.Remove(product);
        await _context.SaveChangesAsync();

        return NoContent();
    }
}

8. Create ApiAccessViewModel.cs

touch ViewModels/ApiAccessViewModel.cs
code ViewModels/ApiAccessViewModel.cs

Paste:

namespace ProductManagement.ViewModels;

public class ApiAccessViewModel
{
    public string Email { get; set; } = string.Empty;
    public string BaseUrl { get; set; } = string.Empty;
}

9. Create ApiAccessController.cs

touch Controllers/ApiAccessController.cs
code Controllers/ApiAccessController.cs

Paste:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ProductManagement.ViewModels;

namespace ProductManagement.Controllers;

[Authorize]
public class ApiAccessController : Controller
{
    public IActionResult Index()
    {
        return View(new ApiAccessViewModel
        {
            Email = User.Identity?.Name ?? string.Empty,
            BaseUrl = $"{Request.Scheme}://{Request.Host}"
        });
    }
}

10. Create Views/ApiAccess

mkdir -p Views/ApiAccess
touch Views/ApiAccess/Index.cshtml
code Views/ApiAccess/Index.cshtml

Paste:

@model ProductManagement.ViewModels.ApiAccessViewModel

@{
    ViewData["Title"] = "API Access";
}

<h1>API Access</h1>

<div class="alert alert-warning">
    <strong>Training only.</strong>
    This page deliberately displays an access token for curl practice.
</div>

<p>Signed-in user: <strong>@Model.Email</strong></p>
<p>Base URL: <code>@Model.BaseUrl</code></p>

<h2>Generate Bearer Token</h2>

<p>
    Your browser login uses an Identity cookie.
    Re-enter your password to request a separate bearer token.
</p>

<div class="mb-3">
    <label for="apiEmail" class="form-label">Email</label>
    <input id="apiEmail"
           class="form-control"
           value="@Model.Email"
           readonly />
</div>

<div class="mb-3">
    <label for="apiPassword" class="form-label">Password</label>
    <input id="apiPassword"
           type="password"
           class="form-control"
           autocomplete="current-password" />
</div>

<button id="generateToken"
        type="button"
        class="btn btn-primary">
    Generate Bearer Token
</button>

<div id="tokenError"
     class="alert alert-danger mt-3 d-none"></div>

<div id="tokenPanel" class="mt-4 d-none">
    <h3>Access Token</h3>

    <textarea id="accessToken"
              class="form-control"
              rows="7"
              readonly></textarea>

    <button id="copyToken"
            type="button"
            class="btn btn-outline-secondary mt-2">
        Copy Access Token
    </button>

    <p class="mt-3">
        Token type: <strong id="tokenType"></strong><br />
        Expires in: <strong id="expiresIn"></strong> seconds
    </p>

    <details>
        <summary>Show refresh token</summary>
        <textarea id="refreshToken"
                  class="form-control mt-2"
                  rows="5"
                  readonly></textarea>
    </details>
</div>

<hr />

<h2>REST Endpoints</h2>

<table class="table table-striped">
<thead>
<tr><th>Method</th><th>Endpoint</th><th>Purpose</th></tr>
</thead>
<tbody>
<tr><td>GET</td><td><code>@Model.BaseUrl/api/products</code></td><td>List your Products</td></tr>
<tr><td>GET</td><td><code>@Model.BaseUrl/api/products/1</code></td><td>Get one Product</td></tr>
<tr><td>POST</td><td><code>@Model.BaseUrl/api/products</code></td><td>Create a Product</td></tr>
<tr><td>PUT</td><td><code>@Model.BaseUrl/api/products/1</code></td><td>Update a Product</td></tr>
<tr><td>DELETE</td><td><code>@Model.BaseUrl/api/products/1</code></td><td>Delete a Product</td></tr>
</tbody>
</table>

<h2>Authorization Header</h2>
<pre><code>Authorization: Bearer YOUR_ACCESS_TOKEN</code></pre>

@section Scripts {
<script>
(() => {
    const generateButton = document.getElementById("generateToken");
    const errorBox = document.getElementById("tokenError");
    const tokenPanel = document.getElementById("tokenPanel");

    generateButton.addEventListener("click", async () => {
        errorBox.classList.add("d-none");
        tokenPanel.classList.add("d-none");

        const email = document.getElementById("apiEmail").value;
        const password = document.getElementById("apiPassword").value;

        try {
            const response = await fetch(
                "/api/auth/login?useCookies=false",
                {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({
                        email: email,
                        password: password
                    })
                });

            if (!response.ok) {
                throw new Error(
                    "Token request failed. Check your password.");
            }

            const data = await response.json();

            document.getElementById("accessToken").value =
                data.accessToken ?? "";

            document.getElementById("refreshToken").value =
                data.refreshToken ?? "";

            document.getElementById("tokenType").textContent =
                data.tokenType ?? "Bearer";

            document.getElementById("expiresIn").textContent =
                data.expiresIn ?? "";

            tokenPanel.classList.remove("d-none");
            document.getElementById("apiPassword").value = "";
        }
        catch (error) {
            errorBox.textContent = error.message;
            errorBox.classList.remove("d-none");
        }
    });

    document.getElementById("copyToken")
        .addEventListener("click", async () => {
            const token =
                document.getElementById("accessToken").value;

            await navigator.clipboard.writeText(token);
        });
})();
</script>
}

11. Add the API Access Navigation Link

Open:

code Views/Shared/_Layout.cshtml

Add this inside the main navigation list:

@if (User.Identity?.IsAuthenticated == true)
{
    <li class="nav-item">
        <a class="nav-link text-dark"
           asp-area=""
           asp-controller="ApiAccess"
           asp-action="Index">
            API Access
        </a>
    </li>
}

12. Build the Completed Application

cd ~/aspnet-mvc-tutorial/ProductManagement
dotnet build
Checkpoint

Continue only when the build succeeds.

13. Run the Application

dotnet run

Keep this terminal running. Note the HTTPS URL, for example https://localhost:7001. Your port may differ.

14. Log In and Generate a Token

In the browser, open /Identity/Account/Login, log in, then open /ApiAccess. Enter the password again and click Generate Bearer Token. Copy the access token.

15. Open a Second Terminal

Do not stop dotnet run. Open another Xubuntu terminal and run:

cd ~

16. Store the Base URL

Use the exact HTTPS port from the first terminal:

BASE_URL="https://localhost:7001"

Check:

echo "$BASE_URL"

17. Store the Access Token

Paste it between single quotes:

TOKEN='PASTE_ACCESS_TOKEN_HERE'
Credential reminder

Do not paste the token into screenshots, blog posts, or shared terminals.

18. GET — List Your Products

curl -k   -H "Authorization: Bearer $TOKEN"   "$BASE_URL/api/products"

19. Install jq for Easier JSON Reading

sudo apt update
sudo apt install jq

Then:

curl -ks   -H "Authorization: Bearer $TOKEN"   "$BASE_URL/api/products" | jq

20. GET — Retrieve One Product

Choose one ID returned above:

PRODUCT_ID=1

Run:

curl -ki   -H "Authorization: Bearer $TOKEN"   "$BASE_URL/api/products/$PRODUCT_ID"

21. Create a curl Lab Folder

cd ~
mkdir -p api-curl-lab
cd api-curl-lab

22. Create the POST JSON File

touch create-product.json
code create-product.json

Paste:

{
  "name": "USB Hub",
  "description": "Seven-port USB hub",
  "price": 89.90,
  "quantity": 12,
  "categoryId": 2
}

23. POST — Create a Product

curl -ki -X POST   "$BASE_URL/api/products"   -H "Authorization: Bearer $TOKEN"   -H "Content-Type: application/json"   --data @create-product.json

Expected status:

HTTP/1.1 201 Created

24. Find the New Product ID

curl -ks   -H "Authorization: Bearer $TOKEN"   "$BASE_URL/api/products" | jq

Set:

PRODUCT_ID=REPLACE_WITH_NEW_ID

25. Create the PUT JSON File

touch update-product.json
code update-product.json

Paste:

{
  "name": "USB Hub Pro",
  "description": "Updated through the REST API",
  "price": 99.90,
  "quantity": 15,
  "categoryId": 2
}

26. PUT — Update the Product

curl -ki -X PUT   "$BASE_URL/api/products/$PRODUCT_ID"   -H "Authorization: Bearer $TOKEN"   -H "Content-Type: application/json"   --data @update-product.json

Expected:

HTTP/1.1 204 No Content

27. Verify the Update

curl -ks   -H "Authorization: Bearer $TOKEN"   "$BASE_URL/api/products/$PRODUCT_ID" | jq

28. DELETE — Delete the Product

curl -ki -X DELETE   -H "Authorization: Bearer $TOKEN"   "$BASE_URL/api/products/$PRODUCT_ID"

Expected:

HTTP/1.1 204 No Content

29. Verify 404 After Delete

curl -ki   -H "Authorization: Bearer $TOKEN"   "$BASE_URL/api/products/$PRODUCT_ID"

30. Test Without a Token

curl -ki "$BASE_URL/api/products"

The protected API should reject the request.

31. Test an Invalid Token

curl -ki   -H "Authorization: Bearer invalid-token"   "$BASE_URL/api/products"

32. Test Ownership with Two Users

  1. Log in as User A and generate Token A.
  2. Create a Product with Token A and record its ID.
  3. Log out of the browser.
  4. Log in as User B and generate Token B.
  5. In the curl terminal, replace TOKEN with Token B.
  6. GET User A's Product ID.
  7. Try PUT and DELETE against that ID.
  8. Confirm User B cannot retrieve, update, or delete User A's Product.

33. Refresh the Token

Copy the refresh token shown on the API Access page.

cd ~/api-curl-lab
touch refresh.json
code refresh.json

Paste:

{
  "refreshToken": "PASTE_REFRESH_TOKEN_HERE"
}

Then:

curl -ks -X POST   "$BASE_URL/api/auth/refresh"   -H "Content-Type: application/json"   --data @refresh.json | jq

34. Why curl Uses -k

The local ASP.NET Core development certificate may not be trusted by curl. -k skips certificate validation for this local lab only. Do not make this a production habit.

35. Migration Check

No EF Core migration is required because no database entity or table changed.

36. Troubleshooting

curl cannot connect

Confirm dotnet run is still running and BASE_URL uses the correct port.

Token generation fails

Confirm the current user's email/password and verify the Identity API route was mapped in Program.cs.

Login Razor Pages disappear

Verify .AddDefaultUI(), AddRazorPages(), and MapRazorPages() are still present.

POST returns Category does not exist

Use an existing Category ID or set categoryId to null.

37. Part 17A Summary

  • continued from Part 12 ownership;
  • added Identity API bearer-token support;
  • kept normal browser Identity login;
  • created a protected API Access page;
  • created owner-protected Product REST endpoints;
  • used curl for GET, POST, PUT, and DELETE;
  • used JSON request files;
  • tested missing and invalid bearer tokens;
  • tested ownership with two users; and
  • used the refresh-token endpoint.

Appendix — Full Code for Final Verification

Program.cs

using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Data;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();

builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlite(
        builder.Configuration.GetConnectionString(
            "DefaultConnection")));

builder.Services
    .AddIdentityApiEndpoints<IdentityUser>(options =>
    {
        options.SignIn.RequireConfirmedAccount = false;
    })
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultUI()
    .AddDefaultTokenProviders();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

app.MapRazorPages();

app.MapGroup("/api/auth")
    .MapIdentityApi<IdentityUser>();

app.Run();

ProductApiViewModels.cs

using System.ComponentModel.DataAnnotations;

namespace ProductManagement.ViewModels;

public class ProductApiRequestViewModel
{
    [Required]
    [StringLength(100)]
    public string Name { get; set; } = string.Empty;

    [StringLength(500)]
    public string? Description { get; set; }

    [Range(0.01, 1000000)]
    public decimal Price { get; set; }

    [Range(0, 1000000)]
    public int Quantity { get; set; }

    public int? CategoryId { get; set; }
}

public class ProductApiResponseViewModel
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string? Description { get; set; }
    public decimal Price { get; set; }
    public int Quantity { get; set; }
    public int? CategoryId { get; set; }
    public string? CategoryName { get; set; }
}

ProductsApiController.cs

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Data;
using ProductManagement.Models;
using ProductManagement.ViewModels;

namespace ProductManagement.Controllers;

[ApiController]
[Authorize]
[Route("api/products")]
public class ProductsApiController : ControllerBase
{
    private readonly ApplicationDbContext _context;
    private readonly UserManager<IdentityUser> _userManager;

    public ProductsApiController(
        ApplicationDbContext context,
        UserManager<IdentityUser> userManager)
    {
        _context = context;
        _userManager = userManager;
    }

    [HttpGet]
    public async Task<ActionResult<IEnumerable<ProductApiResponseViewModel>>> GetAll()
    {
        var userId = _userManager.GetUserId(User);

        if (userId == null)
            return Unauthorized();

        var products = await _context.Products
            .Where(p => p.OwnerId == userId)
            .Include(p => p.Category)
            .OrderBy(p => p.Name)
            .Select(p => new ProductApiResponseViewModel
            {
                Id = p.Id,
                Name = p.Name,
                Description = p.Description,
                Price = p.Price,
                Quantity = p.Quantity,
                CategoryId = p.CategoryId,
                CategoryName = p.Category != null ? p.Category.Name : null
            })
            .ToListAsync();

        return Ok(products);
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<ProductApiResponseViewModel>> GetById(int id)
    {
        var userId = _userManager.GetUserId(User);

        if (userId == null)
            return Unauthorized();

        var product = await _context.Products
            .Where(p => p.Id == id && p.OwnerId == userId)
            .Include(p => p.Category)
            .Select(p => new ProductApiResponseViewModel
            {
                Id = p.Id,
                Name = p.Name,
                Description = p.Description,
                Price = p.Price,
                Quantity = p.Quantity,
                CategoryId = p.CategoryId,
                CategoryName = p.Category != null ? p.Category.Name : null
            })
            .FirstOrDefaultAsync();

        if (product == null)
            return NotFound();

        return Ok(product);
    }

    [HttpPost]
    public async Task<ActionResult<ProductApiResponseViewModel>> Create(
        ProductApiRequestViewModel request)
    {
        var userId = _userManager.GetUserId(User);

        if (userId == null)
            return Unauthorized();

        if (request.CategoryId.HasValue &&
            !await _context.Categories.AnyAsync(c => c.Id == request.CategoryId.Value))
        {
            return BadRequest(new { message = "Category does not exist." });
        }

        var product = new Product
        {
            Name = request.Name,
            Description = request.Description,
            Price = request.Price,
            Quantity = request.Quantity,
            CategoryId = request.CategoryId,
            OwnerId = userId
        };

        _context.Products.Add(product);
        await _context.SaveChangesAsync();

        return CreatedAtAction(
            nameof(GetById),
            new { id = product.Id },
            new ProductApiResponseViewModel
            {
                Id = product.Id,
                Name = product.Name,
                Description = product.Description,
                Price = product.Price,
                Quantity = product.Quantity,
                CategoryId = product.CategoryId
            });
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(
        int id,
        ProductApiRequestViewModel request)
    {
        var userId = _userManager.GetUserId(User);

        if (userId == null)
            return Unauthorized();

        var product = await _context.Products
            .FirstOrDefaultAsync(p => p.Id == id && p.OwnerId == userId);

        if (product == null)
            return NotFound();

        if (request.CategoryId.HasValue &&
            !await _context.Categories.AnyAsync(c => c.Id == request.CategoryId.Value))
        {
            return BadRequest(new { message = "Category does not exist." });
        }

        product.Name = request.Name;
        product.Description = request.Description;
        product.Price = request.Price;
        product.Quantity = request.Quantity;
        product.CategoryId = request.CategoryId;

        await _context.SaveChangesAsync();

        return NoContent();
    }

    [HttpDelete("{id:int}")]
    public async Task<IActionResult> Delete(int id)
    {
        var userId = _userManager.GetUserId(User);

        if (userId == null)
            return Unauthorized();

        var product = await _context.Products
            .FirstOrDefaultAsync(p => p.Id == id && p.OwnerId == userId);

        if (product == null)
            return NotFound();

        _context.Products.Remove(product);
        await _context.SaveChangesAsync();

        return NoContent();
    }
}

ApiAccessViewModel.cs

namespace ProductManagement.ViewModels;

public class ApiAccessViewModel
{
    public string Email { get; set; } = string.Empty;
    public string BaseUrl { get; set; } = string.Empty;
}

ApiAccessController.cs

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ProductManagement.ViewModels;

namespace ProductManagement.Controllers;

[Authorize]
public class ApiAccessController : Controller
{
    public IActionResult Index()
    {
        return View(new ApiAccessViewModel
        {
            Email = User.Identity?.Name ?? string.Empty,
            BaseUrl = $"{Request.Scheme}://{Request.Host}"
        });
    }
}

Views/ApiAccess/Index.cshtml

@model ProductManagement.ViewModels.ApiAccessViewModel

@{
    ViewData["Title"] = "API Access";
}

<h1>API Access</h1>

<div class="alert alert-warning">
    <strong>Training only.</strong>
    This page deliberately displays an access token for curl practice.
</div>

<p>Signed-in user: <strong>@Model.Email</strong></p>
<p>Base URL: <code>@Model.BaseUrl</code></p>

<h2>Generate Bearer Token</h2>

<p>
    Your browser login uses an Identity cookie.
    Re-enter your password to request a separate bearer token.
</p>

<div class="mb-3">
    <label for="apiEmail" class="form-label">Email</label>
    <input id="apiEmail"
           class="form-control"
           value="@Model.Email"
           readonly />
</div>

<div class="mb-3">
    <label for="apiPassword" class="form-label">Password</label>
    <input id="apiPassword"
           type="password"
           class="form-control"
           autocomplete="current-password" />
</div>

<button id="generateToken"
        type="button"
        class="btn btn-primary">
    Generate Bearer Token
</button>

<div id="tokenError"
     class="alert alert-danger mt-3 d-none"></div>

<div id="tokenPanel" class="mt-4 d-none">
    <h3>Access Token</h3>

    <textarea id="accessToken"
              class="form-control"
              rows="7"
              readonly></textarea>

    <button id="copyToken"
            type="button"
            class="btn btn-outline-secondary mt-2">
        Copy Access Token
    </button>

    <p class="mt-3">
        Token type: <strong id="tokenType"></strong><br />
        Expires in: <strong id="expiresIn"></strong> seconds
    </p>

    <details>
        <summary>Show refresh token</summary>
        <textarea id="refreshToken"
                  class="form-control mt-2"
                  rows="5"
                  readonly></textarea>
    </details>
</div>

<hr />

<h2>REST Endpoints</h2>

<table class="table table-striped">
<thead>
<tr><th>Method</th><th>Endpoint</th><th>Purpose</th></tr>
</thead>
<tbody>
<tr><td>GET</td><td><code>@Model.BaseUrl/api/products</code></td><td>List your Products</td></tr>
<tr><td>GET</td><td><code>@Model.BaseUrl/api/products/1</code></td><td>Get one Product</td></tr>
<tr><td>POST</td><td><code>@Model.BaseUrl/api/products</code></td><td>Create a Product</td></tr>
<tr><td>PUT</td><td><code>@Model.BaseUrl/api/products/1</code></td><td>Update a Product</td></tr>
<tr><td>DELETE</td><td><code>@Model.BaseUrl/api/products/1</code></td><td>Delete a Product</td></tr>
</tbody>
</table>

<h2>Authorization Header</h2>
<pre><code>Authorization: Bearer YOUR_ACCESS_TOKEN</code></pre>

@section Scripts {
<script>
(() => {
    const generateButton = document.getElementById("generateToken");
    const errorBox = document.getElementById("tokenError");
    const tokenPanel = document.getElementById("tokenPanel");

    generateButton.addEventListener("click", async () => {
        errorBox.classList.add("d-none");
        tokenPanel.classList.add("d-none");

        const email = document.getElementById("apiEmail").value;
        const password = document.getElementById("apiPassword").value;

        try {
            const response = await fetch(
                "/api/auth/login?useCookies=false",
                {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({
                        email: email,
                        password: password
                    })
                });

            if (!response.ok) {
                throw new Error(
                    "Token request failed. Check your password.");
            }

            const data = await response.json();

            document.getElementById("accessToken").value =
                data.accessToken ?? "";

            document.getElementById("refreshToken").value =
                data.refreshToken ?? "";

            document.getElementById("tokenType").textContent =
                data.tokenType ?? "Bearer";

            document.getElementById("expiresIn").textContent =
                data.expiresIn ?? "";

            tokenPanel.classList.remove("d-none");
            document.getElementById("apiPassword").value = "";
        }
        catch (error) {
            errorBox.textContent = error.message;
            errorBox.classList.remove("d-none");
        }
    });

    document.getElementById("copyToken")
        .addEventListener("click", async () => {
            const token =
                document.getElementById("accessToken").value;

            await navigator.clipboard.writeText(token);
        });
})();
</script>
}

Navigation Block

@if (User.Identity?.IsAuthenticated == true)
{
    <li class="nav-item">
        <a class="nav-link text-dark"
           asp-area=""
           asp-controller="ApiAccess"
           asp-action="Index">
            API Access
        </a>
    </li>
}
Final checkpoint

Part 17A is complete when a logged-in user can generate a bearer token and curl can list, create, update and delete only that user's Products.