From f1210fb88b2918073f5008463178c1e257de1095 Mon Sep 17 00:00:00 2001 From: Vasily Tsybenko Date: Fri, 4 Oct 2024 19:54:50 +0300 Subject: [PATCH] Update README.md and add directory with examples --- README.md | 174 +++++++++------- example_test.go | 196 ------------------ examples/authn-middleware/README.md | 60 ++++++ examples/authn-middleware/config.yml | 13 ++ examples/authn-middleware/main.go | 74 +++++++ examples/idp-test-server/README.md | 47 +++++ examples/idp-test-server/main.go | 102 +++++++++ examples/token-introspection/README.md | 75 +++++++ examples/token-introspection/config.yml | 21 ++ examples/token-introspection/main.go | 108 ++++++++++ idptest/http_server.go | 17 +- idptest/token_handlers.go | 21 +- idptoken/provider_test.go | 4 +- .../testing/server_token_introspector_mock.go | 10 +- jwt/jwt.go | 36 ++-- 15 files changed, 653 insertions(+), 305 deletions(-) delete mode 100644 example_test.go create mode 100644 examples/authn-middleware/README.md create mode 100644 examples/authn-middleware/config.yml create mode 100644 examples/authn-middleware/main.go create mode 100644 examples/idp-test-server/README.md create mode 100644 examples/idp-test-server/main.go create mode 100644 examples/token-introspection/README.md create mode 100644 examples/token-introspection/config.yml create mode 100644 examples/token-introspection/main.go diff --git a/README.md b/README.md index 3b16c7b..a33ec55 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,125 @@ -# Simple library in Go with primitives for performing authentication and authorization +# Toolkit for simplifying authentication and authorization in Go services -The library includes the following packages: -+ `auth` (root directory) - provides authentication and authorization primitives for using on the server side. -+ `jwt` - provides parser for JSON Web Tokens (JWT). -+ `jwks` - provides a client for fetching and caching JSON Web Key Sets (JWKS). -+ `idptoken` - provides a client for fetching and caching Access Tokens from Identity Providers (IDP). -+ `idptest` - provides primitives for testing IDP clients. +## Features -## Examples +- Authenticate HTTP requests with JWT tokens via middleware that can be configured via YAML/JSON file or environment variables. +- Authorize HTTP requests with JWT tokens by verifying access based on the roles in the JWT claims. +- Fetch and cache JSON Web Key Sets (JWKS) from Identity Providers (IDP). +- Introspect Access Tokens via the OAuth 2.0 Token Introspection endpoint. +- Fetch and cache Access Tokens from Identity Providers (IDP). +- Provides primitives for testing authentication and authorization in HTTP services. -### Authenticating requests with JWT tokens +## Authenticate HTTP requests with JWT tokens -The `JWTAuthMiddleware` function creates a middleware that authenticates requests with JWT tokens. +`JWTAuthMiddleware()` creates a middleware that authenticates requests with JWT tokens and puts the parsed JWT claims (`jwt.Claims`) into the request context. -It uses the `JWTParser` to parse and validate JWT. -`JWTParser` can verify JWT tokens signed with RSA (RS256, RS384, RS512) algorithms for now. -It performs /.well-known/openid-configuration request to get the JWKS URL ("jwks_uri" field) and fetches JWKS from there. -For other algorithms `jwt.SignAlgUnknownError` error will be returned. -The `JWTParser` can be created with the `NewJWTParser` function or with the `NewJWTParserWithCachingJWKS` function. -The last one is recommended for production use because it caches public keys (JWKS) that are used for verifying JWT tokens. - -See `Config` struct for more customization options. - -Example: +`jwt.Claims` is an extension of the `RegisteredClaims` struct from the `github.com/golang-jwt/jwt/v5` package. +It contains additional fields, one of which is `Scope` that represents a list of access policies. +They are used for authorization in the typical Acronis service, +and actually can be used in any other application that performs multi-tenant authorization. ```go -package main +package jwt import ( - "net/http" - - "github.com/acronis/go-appkit/log" - "github.com/acronis/go-authkit" + jwtgo "github.com/golang-jwt/jwt/v5" ) -func main() { - jwtConfig := auth.JWTConfig{ - TrustedIssuerURLs: []string{"https://my-idp.com"}, - //TrustedIssuers: map[string]string{"my-idp": "https://my-idp.com"}, // Use TrustedIssuers if you have a custom issuer name. - } - jwtParser, _ := auth.NewJWTParserWithCachingJWKS(&auth.Config{JWT: jwtConfig}, log.NewDisabledLogger()) - authN := auth.JWTAuthMiddleware("MyService", jwtParser) - - srvMux := http.NewServeMux() - srvMux.Handle("/", http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { - _, _ = rw.Write([]byte("Hello, World!")) - })) - srvMux.Handle("/admin", authN(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - //jwtClaims := GetJWTClaimsFromContext(r.Context()) // GetJWTClaimsFromContext is a helper function to get JWT claims from context. - _, _ = rw.Write([]byte("Hello, admin!")) - }))) - - _ = http.ListenAndServe(":8080", srvMux) -} -``` +type Claims struct { + jwtgo.RegisteredClaims + Scope []AccessPolicy `json:"scope,omitempty"` + // ... +} + +// AccessPolicy represents a single access policy which specifies access rights to a tenant or resource +// in the scope of a resource server. +type AccessPolicy struct { + // TenantID is a unique identifier of tenant for which access is granted (if resource is not specified) + // or which the resource is owned by (if resource is specified). + TenantID string `json:"tid,omitempty"` + + // TenantUUID is a UUID of tenant for which access is granted (if the resource is not specified) + // or which the resource is owned by (if the resource is specified). + TenantUUID string `json:"tuid,omitempty"` -```shell -$ curl -w "\nHTTP code: %{http_code}\n" localhost:8080 -Hello, World! -HTTP code: 200 + // ResourceServerID is a unique resource server instance or cluster ID. + ResourceServerID string `json:"rs,omitempty"` -$ curl -w "\nHTTP code: %{http_code}\n" localhost:8080/admin -{"error":{"domain":"MyService","code":"bearerTokenMissing","message":"Authorization bearer token is missing."}} -HTTP code: 401 + // ResourceNamespace is a namespace to which resource belongs within resource server. + // E.g.: account-server, storage-manager, task-manager, alert-manager, etc. + ResourceNamespace string `json:"rn,omitempty"` + + // ResourcePath is a unique identifier of or path to a single resource or resource collection + // in the scope of the resource server and namespace. + ResourcePath string `json:"rp,omitempty"` + + // Role determines what actions are allowed to be performed on the specified tenant or resource. + Role string `json:"role,omitempty"` +} ``` -### Authorizing requests with JWT tokens +`JWTAuthMiddleware()` function accepts two mandatory arguments: `errorDomain` and `JWTParser`. + +The `errorDomain` is usually the name of the service that uses the middleware, and it's goal is distinguishing errors from different services. +It helps to understand where the error occurred and what service caused it. For example, if the "Authorization" HTTP header is missing, the middleware will return 401 with the following response body: +```json +{ + "error": { + "domain": "MyService", + "code": "bearerTokenMissing", + "message": "Authorization bearer token is missing." + } +} +``` + +`JWTParser` is used to parse and validate JWT tokens. +It can be constructed with the `NewJWTParser` right from the YAML/JSON configuration or with the specific `jwt.NewParser()`/`jwt.NewCachingParser()` functions (both of them are used in the `NewJWTParser()` under the hood depending on the configuration). +`jwt.CachingParser` uses LRU in-memory cache for the JWT claims (`jwt.Claims`) to avoid parsing and validating the same token multiple times that can be useful when JWT tokens are large and the service gets a lot of requests from the same client. +`NewJWTParser()` uses `jwks.CachingClient` for fetching and caching JWKS (JSON Web Key Set) that is used for verifying JWT tokens. +This client performs /.well-known/openid-configuration request to get the JWKS URL ("jwks_uri" field) and fetches JWKS from there. +Issuer should be presented in the trusted list, otherwise the middleware will return HTTP response with 401 status code and log a corresponding error message. + +### Authentication middleware example + +Example of the HTTP middleware that authenticates requests with JWT tokens can be found [here](./examples/authn-middleware). + +## Introspect Access Tokens via the OAuth 2.0 Token Introspection endpoint + +Introspection is the process of determining the active state of an access token and the associated metadata. +More information can be found in the [RFC 7662](https://tools.ietf.org/html/rfc7662). + +go-authkit provides a way to introspect any kind of access tokens, not only JWT tokens, via the OAuth 2.0 Token Introspection endpoint. +It performs unmarsalling of the response from the endpoint to the `idptoken.IntrospectionResult` struct which contains the `Active` field that indicates whether the token is active or not. +Additionally, it contains the `TokenType` field that specifies the type of the token and the `Claims` field for presenting the token's metadata in the form of JWT claims. ```go -package main +package idptoken import ( - "net/http" - - "github.com/acronis/go-appkit/log" - "github.com/acronis/go-authkit" + "github.com/acronis/go-authkit/jwt" ) -func main() { - jwtConfig := auth.JWTConfig{TrustedIssuers: map[string]string{"my-idp": idpURL}} - jwtParser, _ := auth.NewJWTParserWithCachingJWKS(&auth.Config{JWT: jwtConfig}, log.NewDisabledLogger()) - authOnlyAdmin := auth.JWTAuthMiddlewareWithVerifyAccess("MyService", jwtParser, - auth.NewVerifyAccessByRolesInJWT(Role{Namespace: "my-service", Name: "admin"})) - - srvMux := http.NewServeMux() - srvMux.Handle("/", http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { - _, _ = rw.Write([]byte("Hello, World!")) - })) - srvMux.Handle("/admin", authOnlyAdmin(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - _, _ = rw.Write([]byte("Hello, admin!")) - }))) - - _ = http.ListenAndServe(":8080", srvMux) +type IntrospectionResult struct { + Active bool `json:"active"` + TokenType string `json:"token_type,omitempty"` + jwt.Claims } ``` -Please see [example_test.go](./example_test.go) for a full version of the example. +The Token Introspection endpoint may be configured statically or obtained from the OpenID Connect Discovery response (GET /.well-known/openid-configuration request for the issuer URL). +In the case of the static configuration, gRPC could be used instead of HTTP for the introspection request (see [idp_token.proto](./idptoken/idp_token.proto) for details). + +`NewTokenIntrospector()` function creates an introspector that can be used to introspect access tokens. + +It's a good practice to protect the introspection endpoint itself. +That's why `NewTokenIntrospector()` accepts the Token Provider (`TokenProvider` interface) that is used to get the Access Token (usually from the Identity Provider) to perform the introspection request with it. +Please keep in mind that the Token Provider should return a valid Access Token that has the necessary permissions to perform the introspection request. + +Additionally, the `NewTokenIntrospector()` accepts the scope filter for filtering out the unnecessary claims from the introspection response. + +### Introspection example + +Example of the access token introspection during the HTTP request authentication can be found [here](./examples/token-introspection). ### Fetching and caching Access Tokens from Identity Providers @@ -122,7 +146,7 @@ func main() { ClientSecret: clientSecret, } provider := idptoken.NewProvider(httpClient, source) - accessToken, err := provider.GetToken() + accessToken, err := provider.GetToken(ctx) if err != nil { log.Fatalf("failed to get access token: %v", err) } diff --git a/example_test.go b/example_test.go deleted file mode 100644 index b97fcbe..0000000 --- a/example_test.go +++ /dev/null @@ -1,196 +0,0 @@ -/* -Copyright © 2024 Acronis International GmbH. - -Released under MIT license. -*/ - -package authkit - -import ( - "context" - "fmt" - "io" - "net/http" - "net/http/httptest" - "time" - - jwtgo "github.com/golang-jwt/jwt/v5" - - "github.com/acronis/go-authkit/idptest" - "github.com/acronis/go-authkit/jwt" -) - -func ExampleJWTAuthMiddleware() { - jwtConfig := JWTConfig{ - TrustedIssuerURLs: []string{"https://my-idp.com"}, - //TrustedIssuers: map[string]string{"my-idp": "https://my-idp.com"}, // Use TrustedIssuers if you have a custom issuer name. - } - jwtParser, _ := NewJWTParser(&Config{JWT: jwtConfig}) - authN := JWTAuthMiddleware("MyService", jwtParser) - - srvMux := http.NewServeMux() - srvMux.Handle("/", http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { - _, _ = rw.Write([]byte("Hello, World!")) - })) - srvMux.Handle("/admin", authN(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - //jwtClaims := GetJWTClaimsFromContext(r.Context()) // GetJWTClaimsFromContext is a helper function to get JWT claims from context. - _, _ = rw.Write([]byte("Hello, admin!")) - }))) - - done := make(chan struct{}) - server := &http.Server{Addr: ":8080", Handler: srvMux} - go func() { - defer close(done) - _ = server.ListenAndServe() - }() - - time.Sleep(time.Second) // Wait for the server to start. - - client := &http.Client{Timeout: time.Second * 30} - - fmt.Println("GET http://localhost:8080/") - resp, _ := client.Get("http://localhost:8080/") - fmt.Println("Status code:", resp.StatusCode) - respBody, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - fmt.Println("Body:", string(respBody)) - - fmt.Println("------") - fmt.Println("GET http://localhost:8080/admin without token") - resp, _ = client.Get("http://localhost:8080/admin") - fmt.Println("Status code:", resp.StatusCode) - respBody, _ = io.ReadAll(resp.Body) - _ = resp.Body.Close() - fmt.Println("Body:", string(respBody)) - - fmt.Println("------") - fmt.Println("GET http://localhost:8080/admin with invalid token") - req, _ := http.NewRequest(http.MethodGet, "http://localhost:8080/admin", http.NoBody) - req.Header["Authorization"] = []string{"Bearer invalid-token"} - resp, _ = client.Do(req) - fmt.Println("Status code:", resp.StatusCode) - respBody, _ = io.ReadAll(resp.Body) - _ = resp.Body.Close() - fmt.Println("Body:", string(respBody)) - - _ = server.Shutdown(context.Background()) - <-done - - // Output: - // GET http://localhost:8080/ - // Status code: 200 - // Body: Hello, World! - // ------ - // GET http://localhost:8080/admin without token - // Status code: 401 - // Body: {"error":{"domain":"MyService","code":"bearerTokenMissing","message":"Authorization bearer token is missing."}} - // ------ - // GET http://localhost:8080/admin with invalid token - // Status code: 401 - // Body: {"error":{"domain":"MyService","code":"authenticationFailed","message":"Authentication is failed."}} -} - -func ExampleJWTAuthMiddlewareWithVerifyAccess() { - jwksServer := httptest.NewServer(&idptest.JWKSHandler{}) - defer jwksServer.Close() - - issuerConfigServer := httptest.NewServer(&idptest.OpenIDConfigurationHandler{JWKSURL: jwksServer.URL}) - defer issuerConfigServer.Close() - - roUserClaims := &jwt.Claims{ - RegisteredClaims: jwtgo.RegisteredClaims{ - Issuer: "my-idp", - ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(2 * time.Hour)), - }, - Scope: []jwt.AccessPolicy{{ResourceNamespace: "my-service", Role: "read-only-user"}}, - } - roUserToken := idptest.MustMakeTokenStringSignedWithTestKey(roUserClaims) - - adminClaims := &jwt.Claims{ - RegisteredClaims: jwtgo.RegisteredClaims{ - Issuer: "my-idp", - ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(2 * time.Hour)), - }, - Scope: []jwt.AccessPolicy{{ResourceNamespace: "my-service", Role: "admin"}}, - } - adminToken := idptest.MustMakeTokenStringSignedWithTestKey(adminClaims) - - jwtConfig := JWTConfig{TrustedIssuers: map[string]string{"my-idp": issuerConfigServer.URL}} - jwtParser, _ := NewJWTParser(&Config{JWT: jwtConfig}) - authOnlyAdmin := JWTAuthMiddleware("MyService", jwtParser, - WithJWTAuthMiddlewareVerifyAccess(NewVerifyAccessByRolesInJWT(Role{Namespace: "my-service", Name: "admin"}))) - - srvMux := http.NewServeMux() - srvMux.Handle("/", http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { - _, _ = rw.Write([]byte("Hello, World!")) - })) - srvMux.Handle("/admin", authOnlyAdmin(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - _, _ = rw.Write([]byte("Hello, admin!")) - }))) - - done := make(chan struct{}) - server := &http.Server{Addr: ":8080", Handler: srvMux} - go func() { - defer close(done) - _ = server.ListenAndServe() - }() - - time.Sleep(time.Second) // Wait for the server to start. - - client := &http.Client{Timeout: time.Second * 30} - - fmt.Println("GET http://localhost:8080/") - resp, _ := client.Get("http://localhost:8080/") - fmt.Println("Status code:", resp.StatusCode) - respBody, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - fmt.Println("Body:", string(respBody)) - - fmt.Println("------") - fmt.Println("GET http://localhost:8080/admin without token") - resp, _ = client.Get("http://localhost:8080/admin") - fmt.Println("Status code:", resp.StatusCode) - respBody, _ = io.ReadAll(resp.Body) - _ = resp.Body.Close() - fmt.Println("Body:", string(respBody)) - - fmt.Println("------") - fmt.Println("GET http://localhost:8080/admin with token of read-only user") - req, _ := http.NewRequest(http.MethodGet, "http://localhost:8080/admin", http.NoBody) - req.Header["Authorization"] = []string{"Bearer " + roUserToken} - resp, _ = client.Do(req) - fmt.Println("Status code:", resp.StatusCode) - respBody, _ = io.ReadAll(resp.Body) - _ = resp.Body.Close() - fmt.Println("Body:", string(respBody)) - - fmt.Println("------") - fmt.Println("GET http://localhost:8080/admin with token of admin user") - req, _ = http.NewRequest(http.MethodGet, "http://localhost:8080/admin", http.NoBody) - req.Header["Authorization"] = []string{"Bearer " + adminToken} - resp, _ = client.Do(req) - fmt.Println("Status code:", resp.StatusCode) - respBody, _ = io.ReadAll(resp.Body) - _ = resp.Body.Close() - fmt.Println("Body:", string(respBody)) - - _ = server.Shutdown(context.Background()) - <-done - - // Output: - // GET http://localhost:8080/ - // Status code: 200 - // Body: Hello, World! - // ------ - // GET http://localhost:8080/admin without token - // Status code: 401 - // Body: {"error":{"domain":"MyService","code":"bearerTokenMissing","message":"Authorization bearer token is missing."}} - // ------ - // GET http://localhost:8080/admin with token of read-only user - // Status code: 403 - // Body: {"error":{"domain":"MyService","code":"authorizationFailed","message":"Authorization is failed."}} - // ------ - // GET http://localhost:8080/admin with token of admin user - // Status code: 200 - // Body: Hello, admin! -} diff --git a/examples/authn-middleware/README.md b/examples/authn-middleware/README.md new file mode 100644 index 0000000..289ea46 --- /dev/null +++ b/examples/authn-middleware/README.md @@ -0,0 +1,60 @@ +# AuthN Middleware Example + +The example below demonstrates how to create a simple HTTP server that authenticates requests with JWT tokens. + +## Usage + +To be able to run this example, you need to have the IDP test server running (see [IDP Test Server Example](../idp-test-server/README.md) for more details). + +```bash +go run ../idp-test-server/main.go +``` + +To start the server, run the following command: + +```bash +go run main.go +``` + +The server will start on port 8080. + +### Trying to access the service without a token + +```shell +$ curl 127.0.0.1:8080 +{"error":{"domain":"MyService","code":"bearerTokenMissing","message":"Authorization bearer token is missing."}} +``` +Service logs: +``` +$ curl 127.0.0.1:8080 +{"error":{"domain":"MyService","code":"bearerTokenMissing","message":"Authorization bearer token is missing."}} +``` + +### Trying to access the service with an invalid token + +```shell +$ curl -H "Authorization: Bearer invalid-token" 127.0.0.1:8080 +{"error":{"domain":"MyService","code":"authenticationFailed","message":"Authentication is failed."}} +``` +Service logs: +``` +{"level":"error","time":"2024-10-06T23:47:11.554193+03:00","msg":"authentication failed","pid":76054,"request_id":"","int_request_id":"","trace_id":"","error":"token is malformed: token contains an invalid number of segments"} +{"level":"error","time":"2024-10-06T23:47:11.554201+03:00","msg":"error in response","pid":76054,"request_id":"","int_request_id":"","trace_id":"","error_code":"authenticationFailed","error_message":"Authentication is failed."} +{"level":"info","time":"2024-10-06T23:47:11.554226+03:00","msg":"response completed in 0.000s","pid":76054,"request_id":"","int_request_id":"","trace_id":"","method":"GET","uri":"/","remote_addr":"127.0.0.1:61538","content_length":0,"user_agent" +:"curl/8.7.1","remote_addr_ip":"127.0.0.1","remote_addr_port":61538,"duration_ms":0,"duration":299,"status":401,"bytes_sent":100} +``` + +### Doing a successful request + +```shell +$ curl -XPOST -u user:user-pwd 127.0.0.1:8081/idp/token +{"access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYzAxYzA3MGNkMDhiYTA4ODA5NzYyZGE2ZTRmNzRhZjE0ZTQ3OTAiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwODEiLCJzdWIiOiJ1c2VyIiwiZXhwIjoxNzI4MjUxMjc3LCJqdGkiOiI2MGIzZjQxYy0wZjA0LTQ4NzQtYjdmNi02MjVkZWJjZmE1ZTQifQ.j7i4_TOqGIOyQFfldmo_lOk7KgDCaq6lXi9xzOWbtrTZJGYMrlLFlgE7Hp8FRU0Npfe7G-N8KswXKevkarZTB80xSwuHwxFbJOMP0J8d0vP-ihGfMXg2WfIxfQD3x0OkynyMz1nTBtquJJ5Yvg5m7xJSKDKU1iCY-e78yJ-yfwT25fHbF3Z5QHvHv8ITsKiwKM3RTDwXxDz1ruMoR4JuhLK7IPmN0eh2P3ZMOpRo-lrXU8b0_UyMeoGMxcA4tSuOLdMLmqzfVTX4wDpcHJCaZROxfK9uZNWPeVOahMp2khg0-b6cDS-CPMIMVsta7LQHqzfvpXuAkB0iJFYLsCDs4A","expires_in":3600} + +$ curl 127.0.0.1:8080 -H"Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYzAxYzA3MGNkMDhiYTA4ODA5NzYyZGE2ZTRmNzRhZjE0ZTQ3OTAiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwODEiLCJzdWIiOiJ1c2VyIiwiZXhwIjoxNzI4MjUxMjc3LCJqdGkiOiI2MGIzZjQxYy0wZjA0LTQ4NzQtYjdmNi02MjVkZWJjZmE1ZTQifQ.j7i4_TOqGIOyQFfldmo_lOk7KgDCaq6lXi9xzOWbtrTZJGYMrlLFlgE7Hp8FRU0Npfe7G-N8KswXKevkarZTB80xSwuHwxFbJOMP0J8d0vP-ihGfMXg2WfIxfQD3x0OkynyMz1nTBtquJJ5Yvg5m7xJSKDKU1iCY-e78yJ-yfwT25fHbF3Z5QHvHv8ITsKiwKM3RTDwXxDz1ruMoR4JuhLK7IPmN0eh2P3ZMOpRo-lrXU8b0_UyMeoGMxcA4tSuOLdMLmqzfVTX4wDpcHJCaZROxfK9uZNWPeVOahMp2khg0-b6cDS-CPMIMVsta7LQHqzfvpXuAkB0iJFYLsCDs4A" +Hello, user +``` +Service logs: +``` +{"level":"info","time":"2024-10-06T23:48:14.106249+03:00","msg":"2 keys fetched (jwks_url: http://127.0.0.1:8081/idp/keys)","pid":76054} +{"level":"info","time":"2024-10-06T23:48:14.10667+03:00","msg":"response completed in 0.002s","pid":76054,"request_id":"","int_request_id":"","trace_id":"","method":"GET","uri":"/","remote_addr":"127.0.0.1:61581","content_length":0,"user_agent":"curl/8.7.1","remote_addr_ip":"127.0.0.1","remote_addr_port":61581,"duration_ms":2,"duration":2116,"status":200,"bytes_sent":11} +``` diff --git a/examples/authn-middleware/config.yml b/examples/authn-middleware/config.yml new file mode 100644 index 0000000..53b3ea5 --- /dev/null +++ b/examples/authn-middleware/config.yml @@ -0,0 +1,13 @@ +log: + level: info + format: json + output: stdout +auth: + jwt: + trustedIssuerUrls: + - http://127.0.0.1:8081 + claimsCache: + enabled: true + maxEntries: 1000 + introspection: + enabled: true \ No newline at end of file diff --git a/examples/authn-middleware/main.go b/examples/authn-middleware/main.go new file mode 100644 index 0000000..664f721 --- /dev/null +++ b/examples/authn-middleware/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "errors" + "fmt" + golog "log" + "net/http" + + "github.com/acronis/go-appkit/config" + "github.com/acronis/go-appkit/httpserver/middleware" + "github.com/acronis/go-appkit/log" + + "github.com/acronis/go-authkit" +) + +const ( + serviceErrorDomain = "MyService" + serviceEnvVarPrefix = "MY_SERVICE" +) + +func main() { + if err := runApp(); err != nil { + golog.Fatal(err) + } +} + +func runApp() error { + cfg := NewAppConfig() + if err := config.NewDefaultLoader(serviceEnvVarPrefix).LoadFromFile("config.yml", config.DataTypeYAML, cfg); err != nil { + return fmt.Errorf("load config: %w", err) + } + + logger, loggerClose := log.NewLogger(cfg.Log) + defer loggerClose() + + jwtParser, err := authkit.NewJWTParser(cfg.Auth, authkit.WithJWTParserLogger(logger)) + if err != nil { + return fmt.Errorf("create JWT parser: %w", err) + } + + logMw := middleware.Logging(logger) + authNMw := authkit.JWTAuthMiddleware(serviceErrorDomain, jwtParser) + + srvMux := http.NewServeMux() + srvMux.Handle("/", logMw(authNMw(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + jwtClaims := authkit.GetJWTClaimsFromContext(r.Context()) // get JWT claims from the request context + _, _ = rw.Write([]byte(fmt.Sprintf("Hello, %s", jwtClaims.Subject))) + })))) + if err = http.ListenAndServe(":8080", srvMux); err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("listen and HTTP server: %w", err) + } + + return nil +} + +type AppConfig struct { + Auth *authkit.Config `config:"auth"` + Log *log.Config `config:"log"` +} + +func NewAppConfig() *AppConfig { + return &AppConfig{ + Log: log.NewConfig(), + Auth: authkit.NewConfig(), + } +} + +func (c *AppConfig) SetProviderDefaults(dp config.DataProvider) { + config.CallSetProviderDefaultsForFields(c, dp) +} + +func (c *AppConfig) Set(dp config.DataProvider) error { + return config.CallSetForFields(c, dp) +} diff --git a/examples/idp-test-server/README.md b/examples/idp-test-server/README.md new file mode 100644 index 0000000..c218d56 --- /dev/null +++ b/examples/idp-test-server/README.md @@ -0,0 +1,47 @@ +# IDP Test Server Example + +This is a simple test server that can be used to test the IDP client. +It demonstrates how the `idptest` package can be used to create a simple IDP server. + +## Usage + +To start the server, run the following command: + +```bash +go run main.go +``` + +The server will start on port 8081. + +Getting well-known configuration: +```bash +$ curl 127.0.0.1:8081/.well-known/openid-configuration +{"token_endpoint":"http://127.0.0.1:8081/idp/token","introspection_endpoint":"http://127.0.0.1:8081/idp/introspect_token","jwks_uri":"http://127.0.0.1:8081/idp/keys"} +``` + +Issuing JWT tokens: +```bash +$ curl -XPOST -u user:user-pwd 127.0.0.1:8081/idp/token +{"access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYzAxYzA3MGNkMDhiYTA4ODA5NzYyZGE2ZTRmNzRhZjE0ZTQ3OTAiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwODEiLCJzdWIiOiJ1c2VyIiwiZXhwIjoxNzI4MjUwMTIzLCJqdGkiOiI0NGFkOTMxNy0xNTNiLTRjYjUtYmQ3ZC0wMWJiOGRjOGJlZjAifQ.N-2p4qIBHsVMrsvHoxdTgeYa72oNQRa4vVXzsV9c9Km_sPhFKPaVIqthKxDHvoh_DUCpuVRBFOXc7thXL3vB2fMBU9YQOarC6Mfd8Q28vUQ_C05PJl2DsM29Y3LEXBpXAzjIKdcCkMnGvSf74gaVkbD5ehzxmpMWJ8k2xoLVLZOyUxncColiPYD6Srs_etmF1ODJ9quuen_ZwxH0tpjJ6pv7rFWMdjrFbmjQj-JgzWRL-aiuvcdiGccjJ8YlmnwGZNWNYXZaoug7p0Hci-yB6TmB_f36_1sydAOp8wiiSZ7ECjPjKc2gzLaazvbxCqY0pXBtXWT06a83tpF7PDg1jg","expires_in":3600} + +$ curl -XPOST -u admin:admin-pwd 127.0.0.1:8081/idp/token # token's scope for admin will be [{"rn": "my_service","role": "admin"}] +{"access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYzAxYzA3MGNkMDhiYTA4ODA5NzYyZGE2ZTRmNzRhZjE0ZTQ3OTAiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwODEiLCJzdWIiOiJhZG1pbiIsImV4cCI6MTcyODI1MDE0NSwianRpIjoiMmUxNTM1YmMtNTlmNC00YWU2LTg5ZTEtMmRjNTQyY2U3NTlhIiwic2NvcGUiOlt7InJuIjoibXlfc2VydmljZSIsInJvbGUiOiJhZG1pbiJ9XX0.JyEcmgoThSYTHTRcFeNStwdAqN7W2pqeO456VTsEI7zWX0EDHr4-HIj4iLDoIyGQzsSMoUcnSEon2thKki6DUkXqc9Kg4iNYgUVVRp6lK5oBqFYGOBj8rlsInEZ03OuoSBhmfdD07EgoHySOXTrZqxFrBJ_mnzvLfA0wXVtwUgKCQBRWQIyAWg-HQotSs7qU_pmmSDgToRlBN5m-j1rkmPay4g5yOjiuRj5l9IxmjxDt_RuOK1-aId2NOT4Jomf4vijSwAG69owgzuPtLqLaFVYQ-bplpZ5EFaPF7f99KuZN2HphhMhuPEx6xuNxKafXQo1NXENjoDvAzHc3TcvcOw","expires_in":3600} + +$ curl -XPOST -u admin2:admin2-pwd 127.0.0.1:8081/idp/token # token's scope for admin2 will be empty +{"access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYzAxYzA3MGNkMDhiYTA4ODA5NzYyZGE2ZTRmNzRhZjE0ZTQ3OTAiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwODEiLCJzdWIiOiJhZG1pbjIiLCJleHAiOjE3MjgyODk4OTQsImp0aSI6IjJiZDc3ZjJhLTBlYTUtNGJmYi04NDU1LWMwZWZjNDM3MDdmNyJ9.Yk4Z6fkiLCGIsAz7qRX1twwLuHt7lXB7KRvpshnPg87b6lnkEhZry7x9aCW6PSjSHvWiGiGhKBRYfhqtHw0umJFf5GtNprRJLj6kMj9Z2HFLirCpjeyMECszcDo-NAqjt-8BcZEis_aCoxXTe_NWE1KjncflhWUMX5XOE4VS8aZn2y2OgGUW1E1qO3uX3H69bt03BB0ZYTPdltKngJcV2HzJuZ9017qKMd3uDBE5W1wVyOleM8Yr-7IaUztQOxyjKbPdsBU8n1F64zm1-RzEriYSq1G6oJfqEkDNc1q0oThM_zN72056vLi5xWUHRbsN7CzZ5WeGQ5vcpUE3PqRquw","expires_in":3600} +``` + +Introspecting JWT tokens: +```bash +# introspecting user's token +$ curl -XPOST -H"Authorization: Bearer token-with-introspection-permission" 127.0.0.1:8081/idp/introspect_token -d token=eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYzAxYzA3MGNkMDhiYTA4ODA5NzYyZGE2ZTRmNzRhZjE0ZTQ3OTAiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwODEiLCJzdWIiOiJ1c2VyIiwiZXhwIjoxNzI4MjUwMTIzLCJqdGkiOiI0NGFkOTMxNy0xNTNiLTRjYjUtYmQ3ZC0wMWJiOGRjOGJlZjAifQ.N-2p4qIBHsVMrsvHoxdTgeYa72oNQRa4vVXzsV9c9Km_sPhFKPaVIqthKxDHvoh_DUCpuVRBFOXc7thXL3vB2fMBU9YQOarC6Mfd8Q28vUQ_C05PJl2DsM29Y3LEXBpXAzjIKdcCkMnGvSf74gaVkbD5ehzxmpMWJ8k2xoLVLZOyUxncColiPYD6Srs_etmF1ODJ9quuen_ZwxH0tpjJ6pv7rFWMdjrFbmjQj-JgzWRL-aiuvcdiGccjJ8YlmnwGZNWNYXZaoug7p0Hci-yB6TmB_f36_1sydAOp8wiiSZ7ECjPjKc2gzLaazvbxCqY0pXBtXWT06a83tpF7PDg1jg +{"active":true,"iss":"http://127.0.0.1:8081","sub":"user","exp":1728250123,"jti":"44ad9317-153b-4cb5-bd7d-01bb8dc8bef0"} + +# introspecting admin's token +$ curl -XPOST -H"Authorization: Bearer token-with-introspection-permission" 127.0.0.1:8081/idp/introspect_token -d token=eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYzAxYzA3MGNkMDhiYTA4ODA5NzYyZGE2ZTRmNzRhZjE0ZTQ3OTAiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwODEiLCJzdWIiOiJhZG1pbiIsImV4cCI6MTcyODI1MDE0NSwianRpIjoiMmUxNTM1YmMtNTlmNC00YWU2LTg5ZTEtMmRjNTQyY2U3NTlhIiwic2NvcGUiOlt7InJuIjoibXlfc2VydmljZSIsInJvbGUiOiJhZG1pbiJ9XX0.JyEcmgoThSYTHTRcFeNStwdAqN7W2pqeO456VTsEI7zWX0EDHr4-HIj4iLDoIyGQzsSMoUcnSEon2thKki6DUkXqc9Kg4iNYgUVVRp6lK5oBqFYGOBj8rlsInEZ03OuoSBhmfdD07EgoHySOXTrZqxFrBJ_mnzvLfA0wXVtwUgKCQBRWQIyAWg-HQotSs7qU_pmmSDgToRlBN5m-j1rkmPay4g5yOjiuRj5l9IxmjxDt_RuOK1-aId2NOT4Jomf4vijSwAG69owgzuPtLqLaFVYQ-bplpZ5EFaPF7f99KuZN2HphhMhuPEx6xuNxKafXQo1NXENjoDvAzHc3TcvcOw +{"active":true,"iss":"http://127.0.0.1:8081","sub":"admin","exp":1728250145,"jti":"2e1535bc-59f4-4ae6-89e1-2dc542ce759a","scope":[{"rn":"my_service","role":"admin"}]} + +# introspecting admin2's token +$ curl -XPOST -H"Authorization: Bearer token-with-introspection-permission" 127.0.0.1:8081/idp/introspect_token -d token=eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYzAxYzA3MGNkMDhiYTA4ODA5NzYyZGE2ZTRmNzRhZjE0ZTQ3OTAiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwODEiLCJzdWIiOiJhZG1pbjIiLCJleHAiOjE3MjgyODk4OTQsImp0aSI6IjJiZDc3ZjJhLTBlYTUtNGJmYi04NDU1LWMwZWZjNDM3MDdmNyJ9.Yk4Z6fkiLCGIsAz7qRX1twwLuHt7lXB7KRvpshnPg87b6lnkEhZry7x9aCW6PSjSHvWiGiGhKBRYfhqtHw0umJFf5GtNprRJLj6kMj9Z2HFLirCpjeyMECszcDo-NAqjt-8BcZEis_aCoxXTe_NWE1KjncflhWUMX5XOE4VS8aZn2y2OgGUW1E1qO3uX3H69bt03BB0ZYTPdltKngJcV2HzJuZ9017qKMd3uDBE5W1wVyOleM8Yr-7IaUztQOxyjKbPdsBU8n1F64zm1-RzEriYSq1G6oJfqEkDNc1q0oThM_zN72056vLi5xWUHRbsN7CzZ5WeGQ5vcpUE3PqRquw +{"active":true,"iss":"http://127.0.0.1:8081","sub":"admin2","exp":1728289894,"jti":"2bd77f2a-0ea5-4bfb-8455-c0efc43707f7","scope":[{"rn":"my_service","role":"admin"}]} +``` \ No newline at end of file diff --git a/examples/idp-test-server/main.go b/examples/idp-test-server/main.go new file mode 100644 index 0000000..c47faff --- /dev/null +++ b/examples/idp-test-server/main.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "errors" + "github.com/acronis/go-authkit" + golog "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/acronis/go-appkit/httpserver/middleware" + "github.com/acronis/go-appkit/log" + jwtgo "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + + "github.com/acronis/go-authkit/idptest" + "github.com/acronis/go-authkit/idptoken" + "github.com/acronis/go-authkit/jwks" + "github.com/acronis/go-authkit/jwt" +) + +func main() { + if err := runApp(); err != nil { + golog.Fatal(err) + } +} +func runApp() error { + const idpAddr = "127.0.0.1:8081" + + logger, loggerClose := log.NewLogger(&log.Config{Output: log.OutputStdout, Level: log.LevelInfo, Format: log.FormatJSON}) + defer loggerClose() + + jwtParser := jwt.NewParser(jwks.NewCachingClient(&http.Client{Timeout: time.Second * 30}, logger), logger) + _ = jwtParser.AddTrustedIssuerURL("http://" + idpAddr) + idpSrv := idptest.NewHTTPServer( + idptest.WithHTTPAddress(idpAddr), + idptest.WithHTTPMiddleware(middleware.Logging(logger)), + idptest.WithHTTPClaimsProvider(&demoClaimsProvider{issuer: "http://" + idpAddr}), + idptest.WithHTTPTokenIntrospector(&demoTokenIntrospector{jwtParser: jwtParser}), + ) + if err := idpSrv.StartAndWaitForReady(time.Second * 3); err != nil { + return err + } + logger.Info("IDP server is running on " + idpAddr) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + + if stopErr := idpSrv.Shutdown(context.Background()); stopErr != nil && !errors.Is(stopErr, http.ErrServerClosed) { + return stopErr + } + return nil +} + +type demoTokenIntrospector struct { + jwtParser *jwt.Parser +} + +func (dti *demoTokenIntrospector) IntrospectToken(r *http.Request, token string) (idptoken.IntrospectionResult, error) { + if bearerToken := authkit.GetBearerTokenFromRequest(r); bearerToken != "token-with-introspection-permission" { + return idptoken.IntrospectionResult{}, idptest.ErrUnauthorized + } + claims, err := dti.jwtParser.Parse(r.Context(), token) + if err != nil { + return idptoken.IntrospectionResult{}, nil + } + if claims.Subject == "admin2" { + claims.Scope = append(claims.Scope, jwt.AccessPolicy{ResourceNamespace: "my_service", Role: "admin"}) + } + return idptoken.IntrospectionResult{Active: true, Claims: *claims}, nil +} + +type demoClaimsProvider struct { + issuer string +} + +func (dcp *demoClaimsProvider) Provide(r *http.Request) (jwt.Claims, error) { + username, password, ok := r.BasicAuth() + if !ok { + return jwt.Claims{}, idptest.ErrUnauthorized + } + var claims jwt.Claims + switch { + case username == "user" && password == "user-pwd": + claims.Subject = "user" + case username == "admin" && password == "admin-pwd": + claims.Subject = "admin" + claims.Scope = []jwt.AccessPolicy{{ResourceNamespace: "my_service", Role: "admin"}} + case username == "admin2" && password == "admin2-pwd": + claims.Subject = "admin2" + default: + return jwt.Claims{}, idptest.ErrUnauthorized + } + claims.Issuer = dcp.issuer + claims.ID = uuid.NewString() + claims.ExpiresAt = jwtgo.NewNumericDate(time.Now().Add(time.Hour)) + return claims, nil +} diff --git a/examples/token-introspection/README.md b/examples/token-introspection/README.md new file mode 100644 index 0000000..8d0f81e --- /dev/null +++ b/examples/token-introspection/README.md @@ -0,0 +1,75 @@ +# Token Introspection Example + +The example below demonstrates how to create a simple HTTP server that authenticates requests with JWT tokens using the token introspection endpoint. +Additionally, it performs authorization based on the introspected token's scope. + +## Usage + +To be able to run this example, you need to have the IDP test server running (see [IDP Test Server Example](../idp-test-server/README.md) for more details). + +```bash +go run ../idp-test-server/main.go +``` + +To start the server, run the following command: + +```bash +go run main.go +``` + +The server will start on port 8080. + +### Trying to access the service without a token + +```shell +$ curl 127.0.0.1:8080 +{"error":{"domain":"MyService","code":"bearerTokenMissing","message":"Authorization bearer token is missing."}} +``` +Service logs: +``` +{"level":"error","time":"2024-10-07T10:18:02.976885+03:00","msg":"error in response","pid":83030,"request_id":"","int_request_id":"","trace_id":"","error_code":"bearerTokenMissing","error_message":"Authorization bearer token is missing."} +{"level":"info","time":"2024-10-07T10:18:02.977616+03:00","msg":"response completed in 0.001s","pid":83030,"request_id":"","int_request_id":"","trace_id":"","method":"GET","uri":"/","remote_addr":"127.0.0.1:50735","content_length":0,"user_agent":"curl/8.7.1","remote_addr_ip":"127.0.0.1","remote_addr_port":50735,"duration_ms":0,"duration":798,"status":401,"bytes_sent":111} +``` + +### Doing requests with a user's token + +```shell +$ curl -XPOST -u user:user-pwd 127.0.0.1:8081/idp/token +{"access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYzAxYzA3MGNkMDhiYTA4ODA5NzYyZGE2ZTRmNzRhZjE0ZTQ3OTAiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwODEiLCJzdWIiOiJ1c2VyIiwiZXhwIjoxNzI4Mjk0ODY5LCJqdGkiOiI0NzRhZTRiYS0wMDkyLTQwZmItOGY2MS02NGFjMWE5NTQwNjgifQ.mIDx9XuqRZmaWtshrSMGuzC2ONQDOqliwuiCctQVCS_a_U19mbs2pbSNJXVd8TmPb2abP7ANgaF9htyuJohdyaIcgFU92dK_ParunHH-qihkMwfTyUMMoQu4YQWSZhc8MBY5xQBb1LchV2uYxc1m402E1nvuXZY4FGbYxy8tdaTMMTJWBjStouMZ0meSDvwSP2mu7J8pCD4V3J6Um4gxtfaesovdyXahdlCwh34e0ey2_KcIuGR3QCOJYNRyEG2CYuMe5mfSrC5f0PkLpBY3G94pSJ_naf0qg4Xz-qmezA1KmAIqWUWXI1jS9UFTwuM4A7M0vPHbU3TXBcIW_yXoQw","expires_in":3600} + +$ curl -XPOST -H"Authorization: Bearer token-with-introspection-permission" 127.0.0.1:8081/idp/introspect_token -d token=eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYzAxYzA3MGNkMDhiYTA4ODA5NzYyZGE2ZTRmNzRhZjE0ZTQ3OTAiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwODEiLCJzdWIiOiJ1c2VyIiwiZXhwIjoxNzI4Mjk0ODY5LCJqdGkiOiI0NzRhZTRiYS0wMDkyLTQwZmItOGY2MS02NGFjMWE5NTQwNjgifQ.mIDx9XuqRZmaWtshrSMGuzC2ONQDOqliwuiCctQVCS_a_U19mbs2pbSNJXVd8TmPb2abP7ANgaF9htyuJohdyaIcgFU92dK_ParunHH-qihkMwfTyUMMoQu4YQWSZhc8MBY5xQBb1LchV2uYxc1m402E1nvuXZY4FGbYxy8tdaTMMTJWBjStouMZ0meSDvwSP2mu7J8pCD4V3J6Um4gxtfaesovdyXahdlCwh34e0ey2_KcIuGR3QCOJYNRyEG2CYuMe5mfSrC5f0PkLpBY3G94pSJ_naf0qg4Xz-qmezA1KmAIqWUWXI1jS9UFTwuM4A7M0vPHbU3TXBcIW_yXoQw +{"active":true,"iss":"http://127.0.0.1:8081","sub":"user","exp":1728294869,"jti":"474ae4ba-0092-40fb-8f61-64ac1a954068"} + +$ curl 127.0.0.1:8080 -H"Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYzAxYzA3MGNkMDhiYTA4ODA5NzYyZGE2ZTRmNzRhZjE0ZTQ3OTAiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwODEiLCJzdWIiOiJ1c2VyIiwiZXhwIjoxNzI4Mjk0ODY5LCJqdGkiOiI0NzRhZTRiYS0wMDkyLTQwZmItOGY2MS02NGFjMWE5NTQwNjgifQ.mIDx9XuqRZmaWtshrSMGuzC2ONQDOqliwuiCctQVCS_a_U19mbs2pbSNJXVd8TmPb2abP7ANgaF9htyuJohdyaIcgFU92dK_ParunHH-qihkMwfTyUMMoQu4YQWSZhc8MBY5xQBb1LchV2uYxc1m402E1nvuXZY4FGbYxy8tdaTMMTJWBjStouMZ0meSDvwSP2mu7J8pCD4V3J6Um4gxtfaesovdyXahdlCwh34e0ey2_KcIuGR3QCOJYNRyEG2CYuMe5mfSrC5f0PkLpBY3G94pSJ_naf0qg4Xz-qmezA1KmAIqWUWXI1jS9UFTwuM4A7M0vPHbU3TXBcIW_yXoQw" +Hello, user + +$ curl 127.0.0.1:8080/admin -H"Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYzAxYzA3MGNkMDhiYTA4ODA5NzYyZGE2ZTRmNzRhZjE0ZTQ3OTAiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwODEiLCJzdWIiOiJ1c2VyIiwiZXhwIjoxNzI4Mjk0ODY5LCJqdGkiOiI0NzRhZTRiYS0wMDkyLTQwZmItOGY2MS02NGFjMWE5NTQwNjgifQ.mIDx9XuqRZmaWtshrSMGuzC2ONQDOqliwuiCctQVCS_a_U19mbs2pbSNJXVd8TmPb2abP7ANgaF9htyuJohdyaIcgFU92dK_ParunHH-qihkMwfTyUMMoQu4YQWSZhc8MBY5xQBb1LchV2uYxc1m402E1nvuXZY4FGbYxy8tdaTMMTJWBjStouMZ0meSDvwSP2mu7J8pCD4V3J6Um4gxtfaesovdyXahdlCwh34e0ey2_KcIuGR3QCOJYNRyEG2CYuMe5mfSrC5f0PkLpBY3G94pSJ_naf0qg4Xz-qmezA1KmAIqWUWXI1jS9UFTwuM4A7M0vPHbU3TXBcIW_yXoQw" +{"error":{"domain":"MyService","code":"authorizationFailed","message":"Authorization is failed."}} +``` +Service logs: +``` +{"level":"info","time":"2024-10-07T10:23:23.862735+03:00","msg":"response completed in 0.005s","pid":83030,"request_id":"","int_request_id":"","trace_id":"","method":"GET","uri":"/","remote_addr":"127.0.0.1:50886","content_length":0,"user_agent":"curl/8.7.1","remote_addr_ip":"127.0.0.1","remote_addr_port":50886,"duration_ms":4,"duration":4688,"status":200,"bytes_sent":11} + +{"level":"error","time":"2024-10-07T10:46:21.621356+03:00","msg":"error in response","pid":83410,"request_id":"","int_request_id":"","trace_id":"","error_code":"authorizationFailed","error_message":"Authorization is failed."} +{"level":"info","time":"2024-10-07T10:46:21.621421+03:00","msg":"response completed in 0.002s","pid":83410,"request_id":"","int_request_id":"","trace_id":"","method":"GET","uri":"/admin","remote_addr":"127.0.0.1:51469","content_length":0,"user_a +gent":"curl/8.7.1","remote_addr_ip":"127.0.0.1","remote_addr_port":51469,"duration_ms":1,"duration":1673,"status":403,"bytes_sent":98} +``` + +### Doing requests with an admin2's token + +admin2's token has no access policies in the scope, but after introspection, the service will allow access to the /admin endpoint. + +```shell +$ curl -XPOST -u admin2:admin2-pwd 127.0.0.1:8081/idp/token +{"access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYzAxYzA3MGNkMDhiYTA4ODA5NzYyZGE2ZTRmNzRhZjE0ZTQ3OTAiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwODEiLCJzdWIiOiJhZG1pbjIiLCJleHAiOjE3MjgyOTQ3MjksImp0aSI6ImZkY2QyMzRiLWJmMGEtNDgxNC1hNTIzLTg2MjZlNmI0MDA2YyJ9.ZCkklH8UtCnL6KLV1L1wimNEKJqCBPErzslNBu_Ox9Cahg590nl1TwdnOrIQgjvPTgRzk5hpiFEiXIsCY1uxtxTiWKtRqGPnKRnityvnQ7VMEc7Iwj-pKstoSr5qGBFbzWrn-4U-yD6xCXvj7DsKyTx-zYVGfbhRQXj8lDRIkfv9fTDfl3htZMKuwMi-fVavaOEJ4ZRGVIQSVd0ku_lXwB28hHP90n5MNmZPqAzcI-j-ribVcrfySe6bN8_7n4hmsk8YNFAPQaXsE5WA868LOKJTsVB4IZifIa7d107okjk8JTFuh-Vktkm8KW6H_TX6-UEWlM1kQKPqoXC5KlLecQ","expires_in":3600} + +$ curl -XPOST -H"Authorization: Bearer token-with-introspection-permission" 127.0.0.1:8081/idp/introspect_token -d token=eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYzAxYzA3MGNkMDhiYTA4ODA5NzYyZGE2ZTRmNzRhZjE0ZTQ3OTAiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwODEiLCJzdWIiOiJhZG1pbjIiLCJleHAiOjE3MjgyOTQ3MjksImp0aSI6ImZkY2QyMzRiLWJmMGEtNDgxNC1hNTIzLTg2MjZlNmI0MDA2YyJ9.ZCkklH8UtCnL6KLV1L1wimNEKJqCBPErzslNBu_Ox9Cahg590nl1TwdnOrIQgjvPTgRzk5hpiFEiXIsCY1uxtxTiWKtRqGPnKRnityvnQ7VMEc7Iwj-pKstoSr5qGBFbzWrn-4U-yD6xCXvj7DsKyTx-zYVGfbhRQXj8lDRIkfv9fTDfl3htZMKuwMi-fVavaOEJ4ZRGVIQSVd0ku_lXwB28hHP90n5MNmZPqAzcI-j-ribVcrfySe6bN8_7n4hmsk8YNFAPQaXsE5WA868LOKJTsVB4IZifIa7d107okjk8JTFuh-Vktkm8KW6H_TX6-UEWlM1kQKPqoXC5KlLecQ +{"active":true,"iss":"http://127.0.0.1:8081","sub":"admin2","exp":1728294729,"jti":"fdcd234b-bf0a-4814-a523-8626e6b4006c","scope":[{"rn":"my_service","role":"admin"}]} + +$ curl 127.0.0.1:8080/admin -H"Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYzAxYzA3MGNkMDhiYTA4ODA5NzYyZGE2ZTRmNzRhZjE0ZTQ3OTAiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwODEiLCJzdWIiOiJhZG1pbjIiLCJleHAiOjE3MjgyOTQ3MjksImp0aSI6ImZkY2QyMzRiLWJmMGEtNDgxNC1hNTIzLTg2MjZlNmI0MDA2YyJ9.ZCkklH8UtCnL6KLV1L1wimNEKJqCBPErzslNBu_Ox9Cahg590nl1TwdnOrIQgjvPTgRzk5hpiFEiXIsCY1uxtxTiWKtRqGPnKRnityvnQ7VMEc7Iwj-pKstoSr5qGBFbzWrn-4U-yD6xCXvj7DsKyTx-zYVGfbhRQXj8lDRIkfv9fTDfl3htZMKuwMi-fVavaOEJ4ZRGVIQSVd0ku_lXwB28hHP90n5MNmZPqAzcI-j-ribVcrfySe6bN8_7n4hmsk8YNFAPQaXsE5WA868LOKJTsVB4IZifIa7d107okjk8JTFuh-Vktkm8KW6H_TX6-UEWlM1kQKPqoXC5KlLecQ" +Hi, admin2 +``` +Service logs: +``` +{"level":"info","time":"2024-10-07T10:48:24.885616+03:00","msg":"response completed in 0.003s","pid":84516,"request_id":"","int_request_id":"","trace_id":"","method":"GET","uri":"/admin","remote_addr":"127.0.0.1:51527","content_length":0,"user_agent":"curl/8.7.1","remote_addr_ip":"127.0.0.1","remote_addr_port":51527,"duration_ms":2,"duration":2866,"status":200,"bytes_sent":10} +``` \ No newline at end of file diff --git a/examples/token-introspection/config.yml b/examples/token-introspection/config.yml new file mode 100644 index 0000000..2275eca --- /dev/null +++ b/examples/token-introspection/config.yml @@ -0,0 +1,21 @@ +log: + level: info + format: json + output: stdout +auth: + jwt: + trustedIssuerUrls: + - http://127.0.0.1:8081 + claimsCache: + enabled: true + maxEntries: 1000 + introspection: + enabled: true + claimsCache: # claims cache is used to cache introspection results for tokens that are valid + enabled: true + maxEntries: 1000 + ttl: 1m + negativeCache: # negative cache is used to cache introspection results for tokens that are not valid (e.g. expired) + enabled: true + maxEntries: 1000 + ttl: 5m diff --git a/examples/token-introspection/main.go b/examples/token-introspection/main.go new file mode 100644 index 0000000..67d1050 --- /dev/null +++ b/examples/token-introspection/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "errors" + "fmt" + golog "log" + "net/http" + + "github.com/acronis/go-appkit/config" + "github.com/acronis/go-appkit/httpserver/middleware" + "github.com/acronis/go-appkit/log" + "github.com/acronis/go-authkit/idptoken" + + "github.com/acronis/go-authkit" +) + +const ( + serviceErrorDomain = "MyService" + serviceEnvVarPrefix = "MY_SERVICE" + serviceAccessPolicy = "my_service" +) + +func main() { + if err := runApp(); err != nil { + golog.Fatal(err) + } +} + +func runApp() error { + cfg := NewAppConfig() + if err := config.NewDefaultLoader(serviceEnvVarPrefix).LoadFromFile("config.yml", config.DataTypeYAML, cfg); err != nil { + return fmt.Errorf("load config: %w", err) + } + + logger, loggerClose := log.NewLogger(cfg.Log) + defer loggerClose() + + // create JWT parser and token introspector + jwtParser, err := authkit.NewJWTParser(cfg.Auth, authkit.WithJWTParserLogger(logger)) + if err != nil { + return fmt.Errorf("create JWT parser: %w", err) + } + introspectionScopeFilter := []idptoken.IntrospectionScopeFilterAccessPolicy{ + {ResourceNamespace: serviceAccessPolicy}} + tokenIntrospector, err := authkit.NewTokenIntrospector(cfg.Auth, introspectionTokenProvider{}, + introspectionScopeFilter, authkit.WithTokenIntrospectorLogger(logger)) + + logMw := middleware.Logging(logger) + + // configure JWTAuthMiddleware that performs only authentication via OAuth2 token introspection endpoint + authNMw := authkit.JWTAuthMiddleware(serviceErrorDomain, jwtParser, + authkit.WithJWTAuthMiddlewareTokenIntrospector(tokenIntrospector)) + + // configure JWTAuthMiddleware that performs authentication via token introspection endpoint + // and authorization based on the user's roles + authZMw := authkit.JWTAuthMiddleware(serviceErrorDomain, jwtParser, + authkit.WithJWTAuthMiddlewareTokenIntrospector(tokenIntrospector), + authkit.WithJWTAuthMiddlewareVerifyAccess( + authkit.NewVerifyAccessByRolesInJWT(authkit.Role{Namespace: serviceAccessPolicy, Name: "admin"}))) + + // create HTTP server and start it + srvMux := http.NewServeMux() + // "/" endpoint will be available for all authenticated users + srvMux.Handle("/", logMw(authNMw(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + jwtClaims := authkit.GetJWTClaimsFromContext(r.Context()) // get JWT claims from the request context + _, _ = rw.Write([]byte(fmt.Sprintf("Hello, %s", jwtClaims.Subject))) + })))) + // "/admin" endpoint will be available only for users with the "admin" role + srvMux.Handle("/admin", logMw(authZMw(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + jwtClaims := authkit.GetJWTClaimsFromContext(r.Context()) // get JWT claims from the request context + _, _ = rw.Write([]byte(fmt.Sprintf("Hi, %s", jwtClaims.Subject))) + })))) + if err = http.ListenAndServe(":8080", srvMux); err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("listen and HTTP server: %w", err) + } + + return nil +} + +type AppConfig struct { + Auth *authkit.Config `config:"auth"` + Log *log.Config `config:"log"` +} + +func NewAppConfig() *AppConfig { + return &AppConfig{ + Log: log.NewConfig(), + Auth: authkit.NewConfig(), + } +} + +func (c *AppConfig) SetProviderDefaults(dp config.DataProvider) { + config.CallSetProviderDefaultsForFields(c, dp) +} + +func (c *AppConfig) Set(dp config.DataProvider) error { + return config.CallSetForFields(c, dp) +} + +type introspectionTokenProvider struct { +} + +func (introspectionTokenProvider) GetToken(ctx context.Context, scope ...string) (string, error) { + return "token-with-introspection-permission", nil +} + +func (introspectionTokenProvider) Invalidate() {} diff --git a/idptest/http_server.go b/idptest/http_server.go index 6f71464..d0e7352 100644 --- a/idptest/http_server.go +++ b/idptest/http_server.go @@ -7,6 +7,7 @@ Released under MIT license. package idptest import ( + "errors" "fmt" "net" "net/http" @@ -28,14 +29,16 @@ const ( const localhostWithDynamicPortAddr = "127.0.0.1:0" +var ErrUnauthorized = errors.New("unauthorized") + // HTTPClaimsProvider is an interface for providing JWT claims in HTTP handlers. type HTTPClaimsProvider interface { - Provide(r *http.Request) jwt.Claims + Provide(r *http.Request) (jwt.Claims, error) } // HTTPTokenIntrospector is an interface for introspecting tokens. type HTTPTokenIntrospector interface { - IntrospectToken(r *http.Request, token string) idptoken.IntrospectionResult + IntrospectToken(r *http.Request, token string) (idptoken.IntrospectionResult, error) } type HTTPServerOption func(s *HTTPServer) @@ -100,10 +103,17 @@ func WithHTTPTokenIntrospector(introspector HTTPTokenIntrospector) HTTPServerOpt } } +func WithHTTPMiddleware(mw func(http.Handler) http.Handler) HTTPServerOption { + return func(s *HTTPServer) { + s.middleware = mw + } +} + // HTTPServer is a mock IDP server for testing purposes. type HTTPServer struct { *http.Server addr atomic.Value + middleware func(http.Handler) http.Handler KeysHandler http.Handler TokenHandler http.Handler TokenIntrospectionHandler http.Handler @@ -137,6 +147,9 @@ func NewHTTPServer(options ...HTTPServerOption) *HTTPServer { // nolint:gosec // This server is used for testing purposes only. s.Server = &http.Server{Handler: s.Router} + if s.middleware != nil { + s.Server.Handler = s.middleware(s.Router) + } return s } diff --git a/idptest/token_handlers.go b/idptest/token_handlers.go index a260aa6..6b12ae4 100644 --- a/idptest/token_handlers.go +++ b/idptest/token_handlers.go @@ -8,6 +8,7 @@ package idptest import ( "encoding/json" + "errors" "fmt" "net/http" "sync/atomic" @@ -33,7 +34,15 @@ func (h *TokenHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { return } - claims := h.ClaimsProvider.Provide(r) + claims, err := h.ClaimsProvider.Provide(r) + if err != nil { + if errors.Is(err, ErrUnauthorized) { + http.Error(rw, "Unauthorized", http.StatusUnauthorized) + return + } + http.Error(rw, fmt.Sprintf("Claims provider failed to provide claims: %v", err), http.StatusInternalServerError) + return + } token, err := MakeTokenStringWithHeader(claims, TestKeyID, GetTestRSAPrivateKey(), nil) if err != nil { @@ -88,7 +97,15 @@ func (h *TokenIntrospectionHandler) ServeHTTP(rw http.ResponseWriter, r *http.Re http.Error(rw, "Token is required", http.StatusBadRequest) return } - introspectResult := h.TokenIntrospector.IntrospectToken(r, token) + introspectResult, err := h.TokenIntrospector.IntrospectToken(r, token) + if err != nil { + if errors.Is(err, ErrUnauthorized) { + http.Error(rw, "Unauthorized", http.StatusUnauthorized) + return + } + http.Error(rw, fmt.Sprintf("Token introspection failed: %v", err), http.StatusInternalServerError) + return + } rw.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(rw).Encode(introspectResult); err != nil { diff --git a/idptoken/provider_test.go b/idptoken/provider_test.go index 2f1a572..c0a3363 100644 --- a/idptoken/provider_test.go +++ b/idptoken/provider_test.go @@ -437,7 +437,7 @@ type claimsProviderWithExpiration struct { ExpTime time.Duration } -func (d *claimsProviderWithExpiration) Provide(_ *http.Request) jwt.Claims { +func (d *claimsProviderWithExpiration) Provide(_ *http.Request) (jwt.Claims, error) { claims := jwt.Claims{ // nolint:staticcheck // StandardClaims are used here for test purposes RegisteredClaims: jwtgo.RegisteredClaims{ @@ -460,5 +460,5 @@ func (d *claimsProviderWithExpiration) Provide(_ *http.Request) jwt.Claims { } claims.ExpiresAt = jwtgo.NewNumericDate(time.Now().UTC().Add(d.ExpTime)) - return claims + return claims, nil } diff --git a/internal/testing/server_token_introspector_mock.go b/internal/testing/server_token_introspector_mock.go index 9f4b247..d2d3c11 100644 --- a/internal/testing/server_token_introspector_mock.go +++ b/internal/testing/server_token_introspector_mock.go @@ -50,25 +50,27 @@ func (m *HTTPServerTokenIntrospectorMock) SetScopeForJWTID(jwtID string, scope [ m.jwtScopes[jwtID] = scope } -func (m *HTTPServerTokenIntrospectorMock) IntrospectToken(r *http.Request, token string) idptoken.IntrospectionResult { +func (m *HTTPServerTokenIntrospectorMock) IntrospectToken( + r *http.Request, token string, +) (idptoken.IntrospectionResult, error) { m.Called = true m.LastAuthorizationHeader = r.Header.Get("Authorization") m.LastIntrospectedToken = token m.LastFormValues = r.Form if result, ok := m.introspectionResults[tokenToKey(token)]; ok { - return result + return result, nil } claims, err := m.JWTParser.Parse(r.Context(), token) if err != nil { - return idptoken.IntrospectionResult{Active: false} + return idptoken.IntrospectionResult{Active: false}, nil } result := idptoken.IntrospectionResult{Active: true, TokenType: idptoken.TokenTypeBearer, Claims: *claims} if scopes, ok := m.jwtScopes[claims.ID]; ok { result.Scope = scopes } - return result + return result, nil } func (m *HTTPServerTokenIntrospectorMock) ResetCallsInfo() { diff --git a/jwt/jwt.go b/jwt/jwt.go index f1be148..5373846 100644 --- a/jwt/jwt.go +++ b/jwt/jwt.go @@ -181,42 +181,30 @@ type Claims struct { OwnerTenantUUID string `json:"owner_tuid,omitempty"` } -// AccessPolicy represents a single access policy. +// AccessPolicy represents a single access policy which specifies access rights to a tenant or resource +// in the scope of a resource server. type AccessPolicy struct { - // TenantID equals to tenant ID for which access is granted (if resource is not specified) - // or which resource is owned by (if resource is specified). - // max length is 36 characters (uuid) + // TenantID is a unique identifier of tenant for which access is granted (if resource is not specified) + // or which the resource is owned by (if resource is specified). TenantID string `json:"tid,omitempty"` - // TenantUUID equals to tenant UUID for which access is granted (if resource is not specified) - // or which resource is owned by (if resource is specified). - // max length is 36 characters (uuid) + // TenantUUID is a UUID of tenant for which access is granted (if the resource is not specified) + // or which the resource is owned by (if the resource is specified). TenantUUID string `json:"tuid,omitempty"` - // ResourceServerID must be unique resource server instance or cluster ID. - // max length is 36 characters [a-Z0-9-_] + // ResourceServerID is a unique resource server instance or cluster ID. ResourceServerID string `json:"rs,omitempty"` - // ResourceNamespace AKA resource type, partitions resources within resource server. - // E.g.: storage, task-manager, account-server, resource-manager, policy-manager etc. - // max length is 36 characters [a-Z0-9-_] + // ResourceNamespace is a namespace to which resource belongs within resource server. + // E.g.: account-server, storage-manager, task-manager, alert-manager, etc. ResourceNamespace string `json:"rn,omitempty"` - // ResourcePath AKA resource ID AKA resource pointer, is a unique identifier of - // or path to (in scope of resource server and namespace) a single resource or resource collection - // 'path' notion remind that it can contain segments, each meaningfull to resource server - // i.e. each sub-path can correspond to different resources, and access policies can be assigned with any sub-path granularity - // but resource path will be considered as immutable, - // moving resources 'within' the path will break access control logic on both AuthZ server and resource server sides - // e.g: vms, vm1, queues, queue1 - // max length is 255 characters [a-Z0-9-_] + // ResourcePath is a unique identifier of or path to a single resource or resource collection + // in the scope of the resource server and namespace. ResourcePath string `json:"rp,omitempty"` - // Role - role available for the resource specified by resource id + // Role determines what actions are allowed to be performed on the specified tenant or resource. Role string `json:"role,omitempty"` - - AllowPermissions []string `json:"allow,omitempty"` - DenyPermissions []string `json:"deny,omitempty"` } type validatableClaims struct {