[Keycloak] 結合Net8(Authorization Code Flow)

在前一篇簡單操作了Keycloak admin上的功能

新增了 realm、client、user

接著這篇會先基於 Authorization code flow 跟 net8 做串接

Authorization code flow

先來瞭解一下 Authorization code 的流程,先跟 AI 偷一張圖來用

image

從流程圖中可了解到,大概可以有幾個步驟

  1. 使用者在 client 重新導向到 keycloak 登入頁面
  2. 在 keycloak 登入成功後,返回 Authorization code 給 client(以下簡稱code)
  3. client 送出 code 給 server
  4. server 拿 code 向 keycloak 取得 accessToken 和 refreshToken,返回給client
  5. client 拿著 accessToken 訪問服務
  6. accessToken 過期時,使用 refreshToken 取得新的token

為了方便演示,我會用建立幾隻api

  1. 取得 auth url,讓使用者可以跳轉到 keycloa 進行登入
  2. callback,使用者登入成功後,跳轉且返回 authorization code
  3. get token,使用者傳入 authorization code,Server端跟 keycloak 取得 token
  4. refresh token,使用者傳入 RefreshToken,Server端跟 keycloak 取得新的 token
  5. user info,需要授權的API,驗證 token 有效,解析並返回內容

0. Start

dotnet add package flurl
  • 期間會需要取得當前應用程式的網址,我會從 HttpContext 取得
builder.Services.AddHttpContextAccessor();

private static string AppUrl(IHttpContextAccessor httpContext)
{
    var request = httpContext.HttpContext!.Request;
    return $"{request.Scheme}://{request.Host}";
}

1. Get auth url

新增一隻API /auth 幫使用者產生跳轉到 keycloak 登入的網址

根據 guide,授權的endpoint是:

/realms/{realm-name}/protocol/openid-connect/auth

以及,我希望登入成功後可以導向到 /auth/callback

app.MapPost("/auth", (
    [FromServices] IConfiguration configuration,
    [FromServices] IHttpContextAccessor httpContextAccessor) =>
{
    var authUrl = "http://localhost:8080/realms/MyRealm/protocol/openid-connect/auth";
    var url = authUrl.SetQueryParams(new
    {
        client_id = "MyClient",
        response_type = "code",
        redirect_uri = AppUrl(httpContextAccessor).AppendPathSegments("auth", "callback"),
        scope = "openid",
        state = Guid.NewGuid().ToString("n")
    });
    return url.ToString();
});

執行後測試看看,導向到auth返回的網址應該會發生錯誤,這不是有效的redirectUri

image

回到 keycloak 的 client,把當前應用程式的網址設定上去 Valid Redirect Uris

image

再測試一次,這次可以看到登入頁面,不過登入成功會看到404

因為還沒實做 callback 呢!

2. Callback

接著繼續實做API /auth/callback

除了用戶會被導向回來外,keycloak 也會返回 Authorization code 和 auth 送出的 state

目前我只想要可以把收到的內容印出來,讓我可以進一步的取得token就好

private static void Callback(RouteGroupBuilder group)
{
    group.MapGet("/auth/callback", (string code, string state) =>
    {
        return new 
        {
            Code = code,
            State = state
        };
    })
}

再重新操作一次 auth,如果登入還沒失效,不會出現登入頁面,會直接導向callback

這次就可以完美的拿到callback回來的內容

{
  "code": "e0ce4cdc-2e0f-4624-9c49-2bfa09410a44.bfb3359a-c2c9-445a-b1b4-ea1dee6ddbed.e50af8c1-9acc-49ba-a38b-ee17e224c5c5",
  "state": "5bae638d9b714f99972beef22da705cf"
}

3. Get Token

現在使用者已經拿到 Authorization code

再準備一隻API,可以讓使用者透過 Server 跟 keycloak 取得 token

根據 guide,取得token的endpoint是:

/realms/{realm-name}/protocol/openid-connect/token

app.MapGet("/auth/token", async (
    string code,
    [FromServices] IHttpContextAccessor httpContext) =>
{
    using var client = new HttpClient();
    var tokenUrl = "http://localhost:8080/realms/MyRealm/protocol/openid-connect/token";
    var callbackUrl = AppUrl(httpContext).AppendPathSegments("auth", "callback");
    var response = await client.PostAsync(tokenUrl, new FormUrlEncodedContent(new Dictionary<string, string>
    {
        { "grant_type", "authorization_code" },
        { "code", code },
        { "redirect_uri", callbackUrl },
        { "client_id", "MyClient" },
    }));
    return await response.Content.ReadAsStringAsync();
});

執行然後把 callback 拿到的 code 餵給他,就可以拿到完美的 token 資訊

把 access_token 丟到 Jwt.io 上也可以看到 payload 裡面的資訊

(Code有有效期限,如果失效,可以重新拿一次快點使用看看)

{
    "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJwZ2VNTzFXNGF2Und0NVJYYWNwSkNNLW5hN3VxUndfYlNXVi02LWFBOXZzIn0.eyJleHAiOjE3MzgzMTI0NzMsImlhdCI6MTczODMxMjE3MywiYXV0aF90aW1lIjoxNzM4MzExMzI4LCJqdGkiOiIwZjQyYWFiYS02NDY3LTRkOTgtYmY4Ni04N2ZlNDM0OWRhMmEiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVhbG1zL015UmVhbG0iLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiODFiY2U2N2EtM2JjNC00MzdlLTkwZDUtNTAzNGE2ZDZiMTFjIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiTXlDbGllbnQiLCJzaWQiOiJiZmIzMzU5YS1jMmM5LTQ0NWEtYjFiNC1lYTFkZWU2ZGRiZWQiLCJhY3IiOiIwIiwiYWxsb3dlZC1vcmlnaW5zIjpbIi8qIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJkZWZhdWx0LXJvbGVzLW15cmVhbG0iLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoibXkgdXNlciIsInByZWZlcnJlZF91c2VybmFtZSI6Im15dXNlciIsImdpdmVuX25hbWUiOiJteSIsImZhbWlseV9uYW1lIjoidXNlciIsImVtYWlsIjoidGVzdEB0ZXN0LmNvbSJ9.jj-q-KjurRV93aR7rFeY5XE9ibCxPqe73Sjfl2RTZ6GGSXNNDo-uJD_YhIAs4L_nFmFuK1Xx9tzAPrrR4IbZoP2bbdkwCJaS1vjleJmCxr8ObcZ3gJ5-WyXvNES8lxYWg09rDF4OU-oJVrPRs1msdrkp-p97LnFZwEopZLKPMiwsgC5l7kOHTieCJSyUG6BCNjN_x6NRvZODA78ce1_YmN1oMVnEzH9lytX1C3AVFWtmByN10v0EwCpCr0mVkhWwK2VvVaZae6A7hGEHoCLL-MdVmSv7MR5IY6jV0bAKcHPnCFMrf5T8TsduOvhu1zPR5UsWahu4FEP2tkWT3ZG1ng",
    "expires_in": 300,
    "refresh_expires_in": 1800,
    "refresh_token": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJkNTQzOTI5Ny1jZjBlLTRiNGEtODlkZC02YmQ2YzVjMzk3NGIifQ.eyJleHAiOjE3MzgzMTM5NzMsImlhdCI6MTczODMxMjE3MywianRpIjoiMTY4NjA1YmUtMzU5Zi00MjhmLTk2NGUtM2VlOTI5Y2JiZmJkIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9NeVJlYWxtIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9NeVJlYWxtIiwic3ViIjoiODFiY2U2N2EtM2JjNC00MzdlLTkwZDUtNTAzNGE2ZDZiMTFjIiwidHlwIjoiUmVmcmVzaCIsImF6cCI6Ik15Q2xpZW50Iiwic2lkIjoiYmZiMzM1OWEtYzJjOS00NDVhLWIxYjQtZWExZGVlNmRkYmVkIiwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSByb2xlcyBiYXNpYyBlbWFpbCBhY3Igd2ViLW9yaWdpbnMifQ.he-BOcMP7ci1D3Funh8yT1ibb8H9s-3GLdCtGyBZy_tO9RTbJS4G5ZcP4yQS0LUkLDG3NdoLwZJjDyMjVyWnRA",
    "token_type": "Bearer",
    "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJwZ2VNTzFXNGF2Und0NVJYYWNwSkNNLW5hN3VxUndfYlNXVi02LWFBOXZzIn0.eyJleHAiOjE3MzgzMTI0NzMsImlhdCI6MTczODMxMjE3MywiYXV0aF90aW1lIjoxNzM4MzExMzI4LCJqdGkiOiIzZTA0NzcyOC04NDgzLTRiZjMtYjRlOC0yMDAwMjE3MDY3YzAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVhbG1zL015UmVhbG0iLCJhdWQiOiJNeUNsaWVudCIsInN1YiI6IjgxYmNlNjdhLTNiYzQtNDM3ZS05MGQ1LTUwMzRhNmQ2YjExYyIsInR5cCI6IklEIiwiYXpwIjoiTXlDbGllbnQiLCJzaWQiOiJiZmIzMzU5YS1jMmM5LTQ0NWEtYjFiNC1lYTFkZWU2ZGRiZWQiLCJhdF9oYXNoIjoiT01aZEdWSzJYSElObGplSWVpZVRmUSIsImFjciI6IjAiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6Im15IHVzZXIiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJteXVzZXIiLCJnaXZlbl9uYW1lIjoibXkiLCJmYW1pbHlfbmFtZSI6InVzZXIiLCJlbWFpbCI6InRlc3RAdGVzdC5jb20ifQ.Cm1-OmrwZM6v06pcUjhR1cbbGknm6v3VHF7Cf9pyXTDBSyDHIo0bCJ3KwqLbhrZMZfIhxW9wXpS5NpreIDNpBt7HJulAgG9xORyO8nDPzfj2vN4sguTEXSVKeExlkZods-4anfdidPiR60Ixe1fVUtP1FXyHMHl-vUNisa6aJEBa9E8n9kwitWhPEJTZSzG8DAtCZTNvTmO5ZlvdlGlMQyE5pe_SbAeBWQ7CMDE16CEZDozKx2DpRVz5uBKkeKruKROzfY6O4wbwwNK5pCcPZraQn3n3nr_Q3CCUEZ2ffR4CBucQpoBKLJPtyTf-P6Vom9rfoCNBVg1mPTY3h1-vMw",
    "not-before-policy": 0,
    "session_state": "bfb3359a-c2c9-445a-b1b4-ea1dee6ddbed",
    "scope": "openid profile email"
}

4. User Info

現在有了 AccessToekn,但是應用程式還不認識他,接著準備一隻API,讓我們驗證 keycloak 發行的 accessToken 可以通過授權

這邊需要解析jwt token了,所以加上套件

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

新增相關的服務和 middleware

builder.Services.AddAuthorization();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(r =>
    {
        r.RequireHttpsMetadata = false;
        r.Audience = "account";
        r.MetadataAddress = "http://localhost:8080/realms/MyRealm/.well-known/openid-configuration";
        r.TokenValidationParameters = new TokenValidationParameters
        {
            ValidIssuer = "http://localhost:8080/realms/MyRealm",
        };
    });

app.UseAuthentication();
app.UseAuthorization();

讓 swagger 可以填入 access_token

builder.Services.AddSwaggerGen(r =>
{
    r.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Name = "Authorization",
        Type = SecuritySchemeType.ApiKey,
        Scheme = "Bearer",
        BearerFormat = "JWT",
        In = ParameterLocation.Header,
        Description = "use api `Account/Login` get api token. value pattern: `Bearer {apiToken}`",
    });

    r.AddSecurityRequirement(new OpenApiSecurityRequirement()
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "Bearer"
                },
            },
            new List<string>()
        }
    });
});

新增 api,並且標記他必須授權才能訪問

app.MapGet("/user", (ClaimsPrincipal user) =>
{
    return user.Claims.ToDictionary(c => c.Type, c => c.Value);
}).RequireAuthorization();

執行、取得token、填入token(記得加上 bearer),然後呼叫api /user

accessToken得到了授權,而且我們也拿到Payload裡面的資訊(從Claims)

{
  "exp": "1738313340",
  "iat": "1738313040",
  "auth_time": "1738311328",
  "jti": "02a7f707-e9ae-434c-9a6c-f35544d184f3",
  "iss": "http://localhost:8080/realms/MyRealm",
  "aud": "account",
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": "81bce67a-3bc4-437e-90d5-5034a6d6b11c",
  "typ": "Bearer",
  "azp": "MyClient",
  "sid": "bfb3359a-c2c9-445a-b1b4-ea1dee6ddbed",
  "http://schemas.microsoft.com/claims/authnclassreference": "0",
  "allowed-origins": "/*",
  "realm_access": "{\"roles\":[\"default-roles-myrealm\",\"offline_access\",\"uma_authorization\"]}",
  "resource_access": "{\"account\":{\"roles\":[\"manage-account\",\"manage-account-links\",\"view-profile\"]}}",
  "scope": "openid profile email",
  "email_verified": "true",
  "name": "my user",
  "preferred_username": "myuser",
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname": "my",
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname": "user",
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": "test@test.com"
}

5. Refresh

主要的功能都有了,但是token過期又要重新跑一次流程,接著加上 refresh token 的 api

跟 getToken 相差不大,只要一點點的改動

  1. 把 grant_type 換成 refresh_token
  2. 原本的 code 換成 refresh_token
  3. url 不變
app.MapGet("/refresh", async (string refreshToken, [FromServices] IConfiguration configuration) =>
{
    using var client = new HttpClient();
    var tokenUrl = "http://localhost:8080/realms/MyRealm/protocol/openid-connect/token";
    var response = await client.PostAsync(tokenUrl, new FormUrlEncodedContent(new Dictionary<string, string>
    {
        { "grant_type", "refresh_token" },
        { "refresh_token", refreshToken },
        { "client_id", "MyClient" },
    }));
    return await response.Content.ReadAsStringAsync();
});

接著就可以用 refresh_token 重新拿到跟 getToken 一樣的內容了!

(當然,是新的 token)

Example Code

Github

參考資料

Securing Applications and Services Guide

Secure Your .NET Application With Keycloak: Step-by-Step Guide