Authorization code flow
先來瞭解一下 Authorization code 的流程,先跟 AI 偷一張圖來用
從流程圖中可了解到,大概可以有幾個步驟
- 使用者在 client 重新導向到 keycloak 登入頁面
- 在 keycloak 登入成功後,返回 Authorization code 給 client(以下簡稱code)
- client 送出 code 給 server
- server 拿 code 向 keycloak 取得 accessToken 和 refreshToken,返回給client
- client 拿著 accessToken 訪問服務
- accessToken 過期時,使用 refreshToken 取得新的token
為了方便演示,我會用建立幾隻api
- 取得 auth url,讓使用者可以跳轉到 keycloa 進行登入
- callback,使用者登入成功後,跳轉且返回 authorization code
- get token,使用者傳入 authorization code,Server端跟 keycloak 取得 token
- refresh token,使用者傳入 RefreshToken,Server端跟 keycloak 取得新的 token
- user info,需要授權的API,驗證 token 有效,解析並返回內容
0. Start
- 期間會需要取得當前應用程式的網址,我會從 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
回到 keycloak 的 client,把當前應用程式的網址設定上去 Valid Redirect Uris
再測試一次,這次可以看到登入頁面,不過登入成功會看到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 相差不大,只要一點點的改動
- 把 grant_type 換成
refresh_token
- 原本的 code 換成
refresh_token
- 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
Authorization code flow
先來瞭解一下 Authorization code 的流程,先跟 AI 偷一張圖來用
從流程圖中可了解到,大概可以有幾個步驟
為了方便演示,我會用建立幾隻api
0. Start
建立一個空的 web api 專案
後續會使用到 keycloak 的 api 參照 Securing Applications and Services Guide
Url的操作,我習慣使用套件 Flurl
1. Get auth url
新增一隻API
/auth
幫使用者產生跳轉到 keycloak 登入的網址根據 guide,授權的endpoint是:
以及,我希望登入成功後可以導向到
/auth/callback
執行後測試看看,導向到auth返回的網址應該會發生錯誤,這不是有效的redirectUri
回到 keycloak 的 client,把當前應用程式的網址設定上去
Valid Redirect Uris
再測試一次,這次可以看到登入頁面,不過登入成功會看到404
因為還沒實做 callback 呢!
2. Callback
接著繼續實做API
/auth/callback
除了用戶會被導向回來外,keycloak 也會返回 Authorization code 和 auth 送出的 state
目前我只想要可以把收到的內容印出來,讓我可以進一步的取得token就好
再重新操作一次
auth
,如果登入還沒失效,不會出現登入頁面,會直接導向callback這次就可以完美的拿到callback回來的內容
3. Get Token
現在使用者已經拿到 Authorization code
再準備一隻API,可以讓使用者透過 Server 跟 keycloak 取得 token
根據 guide,取得token的endpoint是:
執行然後把 callback 拿到的 code 餵給他,就可以拿到完美的 token 資訊
把 access_token 丟到 Jwt.io 上也可以看到 payload 裡面的資訊
(Code有有效期限,如果失效,可以重新拿一次快點使用看看)
4. User Info
現在有了 AccessToekn,但是應用程式還不認識他,接著準備一隻API,讓我們驗證 keycloak 發行的 accessToken 可以通過授權
這邊需要解析jwt token了,所以加上套件
新增相關的服務和 middleware
讓 swagger 可以填入 access_token
新增 api,並且標記他必須授權才能訪問
執行、取得token、填入token(記得加上 bearer),然後呼叫api
/user
accessToken得到了授權,而且我們也拿到Payload裡面的資訊(從Claims)
5. Refresh
主要的功能都有了,但是token過期又要重新跑一次流程,接著加上 refresh token 的 api
跟 getToken 相差不大,只要一點點的改動
refresh_token
refresh_token
接著就可以用 refresh_token 重新拿到跟 getToken 一樣的內容了!
(當然,是新的 token)
Example Code
Github
參考資料
Securing Applications and Services Guide
Secure Your .NET Application With Keycloak: Step-by-Step Guide