Skip to content

Commit

Permalink
Client credentials scope (#5)
Browse files Browse the repository at this point in the history
* Adds scope record for scope lists and scope strings
* Adds an optional scope using add_scope
* Adds ScopeList test

* Adds example for client credentials
---------

Co-authored-by: Adam Davies <[email protected]>
  • Loading branch information
ChiefTwoPencils and adz authored Aug 5, 2024
1 parent 562ce20 commit 4eda668
Show file tree
Hide file tree
Showing 4 changed files with 196 additions and 2 deletions.
80 changes: 80 additions & 0 deletions examples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Usage Examples

## Prerequisites

To make use of the example code below, you will first need to follow your Identity Provider's
process for creating a client id and client secret and obtaining the token request URI and
scope value(s).

## Authorization Code

```
// TODO
```

## Client Credentials

> **TIP**
>
> Verify how your authorization server handles the access token scopes for client
> credentials flow. If it is not required to send a scope in the request, use the
> DefaultScope parameterless constructor to omit the scope parameter from the request.
>
> See the [Scope][cc1] type for more details.
```gleam
import gleam/hackney
import gleam/io
import gleam/result
import gleam/uri
import glow_auth.{Client}
import glow_auth/access_token.{decode_token_from_response}
import glow_auth/token_request.{RequestBody, ScopeString, client_credentials}
import glow_auth/uri/uri_builder.{RelativePath}
pub fn main() {
// Replace the values for the let bindings with the values for your auth server
// and client.
let client_id = "<your client id>"
let client_secret = "<your client secret>"
let base_uri = "https://example.com"
let token_endpoint = "token"
let scope = "scope1 scope2[ ... scopeN]"
// Use the values above to create and send a token request to the auth server.
// 1. Create the site Uri to serve as the base path.
use site <- result.then(uri.parse(base_uri))
// 2. Create a Client using the client_id, client_secret, and site.
let client = Client(client_id, client_secret, site)
// 3. Create a request by calling client_credentials with parameters:
// a. The Client created in step (2).
// b. The token path to be appended to the site Uri created in step (1).
// c. The AuthScheme. (RequestBody or AuthHeader)
// d. The Access Token Scope. (ScopeList, ScopeString, or DefaultScope)
let request =
client_credentials(
client,
RelativePath(token_endpoint),
RequestBody,
ScopeString(scope),
)
// 4. Send the token request to the auth server.
let response = hackney.send(request)
// 5. Decode the token from the response body when an Ok.
// Handle a Error response according to your application's needs.
let token = case response {
Ok(wrapped) -> decode_token_from_response(wrapped.body)
_ -> panic
}
// View the Result token in the console.
io.debug(token)
|> Ok
}
```

[cc1]: ./glow_auth/token_request#Scope
2 changes: 1 addition & 1 deletion manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ packages = [
{ name = "gleam_hackney", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib", "hackney"], otp_app = "gleam_hackney", source = "hex", outer_checksum = "066B1A55D37DBD61CC72A1C4EDE43C6015B1797FAF3818C16FE476534C7B6505" },
{ name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" },
{ name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" },
{ name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" },
{ name = "gleam_stdlib", version = "0.39.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "2D7DE885A6EA7F1D5015D1698920C9BAF7241102836CE0C3837A4F160128A9C4" },
{ name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" },
{ name = "hackney", version = "1.20.1", build_tools = ["rebar3"], requirements = ["certifi", "idna", "metrics", "mimerl", "parse_trans", "ssl_verify_fun", "unicode_util_compat"], otp_app = "hackney", source = "hex", outer_checksum = "FE9094E5F1A2A2C0A7D10918FEE36BFEC0EC2A979994CFF8CFE8058CD9AF38E3" },
{ name = "idna", version = "6.1.1", build_tools = ["rebar3"], requirements = ["unicode_util_compat"], otp_app = "idna", source = "hex", outer_checksum = "92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA" },
Expand Down
44 changes: 43 additions & 1 deletion src/glow_auth/token_request.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import gleam/bit_array
import gleam/http/request.{type Request}
import gleam/list
import gleam/string
import gleam/uri.{type Uri}
import glow_auth.{type Client}
Expand Down Expand Up @@ -33,18 +34,35 @@ pub fn authorization_code(
|> token_request_builder.to_token_request()
}

/// Build a token request using just the client id/secret in
/// The [Access Token Scope](https://datatracker.ietf.org/doc/html/rfc6749#section-3.3)
/// is a list of unordered, space-deliminated, case-sensitive strings.
pub type Scope {
/// Respresents the access token scope as a list of individual scope values that can
/// later be joined into a space-delimited string.
ScopeList(List(String))
/// Represents the access token scope as a string of one or more space-deliminated scopes.
/// Useful for a single scope or when the scopes have been pre-joined.
ScopeString(String)
/// Represents the omission of an access token scope.
/// Useful when the authorization server will fall back to a default scope when the scope
/// is not present in the body.
DefaultScope
}

/// Build a token request using the client id/secret and optionally a access token scope in
/// [Client Credentials grant](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4.2)
pub fn client_credentials(
client: Client(body),
token_uri: UriAppendage,
auth_scheme: AuthScheme,
scope: Scope,
) -> Request(String) {
token_uri
|> uri_builder.append(to: client.site)
|> token_request_builder.from_uri()
|> token_request_builder.put_param("grant_type", "client_credentials")
|> add_auth(client, auth_scheme)
|> add_scope(scope)
|> token_request_builder.to_token_request()
}

Expand Down Expand Up @@ -78,6 +96,30 @@ pub type AuthScheme {
RequestBody
}

/// Add a scope to the builder if not a DefaultScope.
pub fn add_scope(
rb: TokenRequestBuilder(a),
scope: Scope,
) -> TokenRequestBuilder(a) {
case scope {
ScopeList(scope_list) -> scope_list |> put_scope(rb)

ScopeString(scope_string) -> [scope_string] |> put_scope(rb)

DefaultScope -> rb
}
}

fn put_scope(
scope: List(String),
rb: TokenRequestBuilder(a),
) -> TokenRequestBuilder(a) {
scope
|> list.map(string.trim)
|> string.join(" ")
|> token_request_builder.put_param(rb, "scope", _)
}

/// Add auth by means of either AuthHeader or RequestBody
pub fn add_auth(
rb: TokenRequestBuilder(a),
Expand Down
72 changes: 72 additions & 0 deletions test/glow_auth/token_request_test.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import gleam/http/request.{type Request}
import gleam/list
import gleam/result
import gleam/string
import gleam/uri
import gleeunit/should
import glow_auth.{Client}
import glow_auth/token_request.{
type Scope, DefaultScope, RequestBody, ScopeList, ScopeString,
client_credentials,
}
import glow_auth/uri/uri_builder

fn make_request(
scope: Scope,
_handler: fn(Request(String)) -> Result(String, Nil),
) -> Result(Request(String), Nil) {
use example <- result.then(uri.parse("example.com"))
let req =
example
|> Client("id", "secret", _)
|> client_credentials(uri_builder.RelativePath("token"), RequestBody, scope)

Ok(req)
}

/// Tests that a scope is added to the request body as a space-deliminated string
/// of trimed scope values from a ScopeList.
pub fn scope_list_test() {
let scopes = [" first ", " second ", " third "]
use req <- make_request(ScopeList(scopes))
req.body
|> extract_scope
|> test_scope("first second third")

Ok("")
}

/// Tests that a scope is added to the request body as a trimmed string of one or
/// more pre-joined, space-deliminated strings.
pub fn scope_string_test() {
let scopes = " third fourth fifth "
use req <- make_request(ScopeString(scopes))
req.body
|> extract_scope
|> test_scope(string.trim(scopes))

Ok("")
}

/// Tests that the scope is not added to the request if it's a DefaultScope.
pub fn default_scope_test() {
use req <- make_request(DefaultScope)
req.body
|> extract_scope
|> should.equal(Error(Nil))

Ok("")
}

fn extract_scope(body: String) -> Result(String, Nil) {
body
|> uri.percent_decode
|> result.unwrap("")
|> string.split("&")
|> list.filter(fn(s) { string.starts_with(s, "scope") })
|> list.first
}

fn test_scope(actual: Result(String, Nil), expected: String) {
actual |> should.equal(Ok("scope=" <> expected))
}

0 comments on commit 4eda668

Please sign in to comment.