├── .editorconfig
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ ├── feature_request.md
│ └── rfc_template.md
└── workflows
│ ├── crystal.yml
│ └── pull_request.yml
├── .gitignore
├── LICENSE
├── README.md
├── authly.png
├── bin
├── ameba
├── ameba.cr
├── authly
└── test_server
├── shard.yml
├── spec
├── authly_spec.cr
├── code_challenge_builder_spec.cr
├── code_spec.cr
├── grant_spec.cr
├── grants
│ ├── authorization_code_spec.cr
│ ├── client_credentials_spec.cr
│ ├── password_spec.cr
│ └── refresh_token_spec.cr
├── spec_helper.cr
└── support
│ ├── handlers_spec.cr
│ ├── settings.cr
│ └── test_server.cr
└── src
├── authly.cr
└── authly
├── access_token.cr
├── authorizable_client.cr
├── authorizable_owner.cr
├── client.cr
├── code.cr
├── code_challenge_builder.cr
├── configuration.cr
├── error.cr
├── grant.cr
├── grants
├── authorization_code.cr
├── client_credentials.cr
├── password.cr
└── refresh_token.cr
├── handler.cr
├── handlers
├── access_token_handler.cr
├── authorization_handler.cr
├── introspect_hanlder.cr
├── refresh_token_handler.cr
├── response_helper.cr
└── revoke_hanlder.cr
├── owner.cr
├── response_type.cr
├── state_store.cr
├── token_manager.cr
└── token_store.cr
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*.cr]
4 | charset = utf-8
5 | end_of_line = lf
6 | insert_final_newline = true
7 | indent_style = space
8 | indent_size = 2
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/rfc_template.md:
--------------------------------------------------------------------------------
1 | # Title (see source to read the comments )
2 |
3 | Authors:
4 | - @githubusername
5 |
6 | ## 1 TL;DR
7 |
8 |
11 |
12 | ## 2 Motivation
13 |
14 |
18 |
19 | ## 3 Proposed Implementation
20 |
21 |
32 |
33 | ## 4 Metrics & Dashboards
34 |
35 |
39 |
40 | ## 5 Drawbacks
41 |
42 |
46 |
47 | ## 6 Alternatives
48 |
49 |
52 |
53 | ## 7 Potential Impact and Dependencies
54 |
55 |
61 |
62 |
63 | ## 8 Unresolved questions
64 |
65 |
68 |
69 | ## 9 Conclusion
70 |
71 |
74 |
75 | ## 10 RFC Process, remove this section when done
76 |
77 |
94 |
95 | - [ ] Copy template
96 | - [ ] Draft RFC (think of it as a wireframe)
97 | - [ ] Share as WIP with folks you trust, to gut-check
98 | - [ ] Send pull request when comfortable
99 | - [ ] Label accordingly
100 | - [ ] Assign reviewers (ask your manager if in doubt)
101 | - [ ] Merge yourself with 2 approved reviews
102 |
103 |
104 | ### Recommendations
105 |
106 | - Tag RFC title with [WIP] if you're still ironing out details.
107 | - Tag RFC title with [newbie] if you're trying out something experimental, or you're not completely convinced of what you're proposing.
108 | - Tag RFC title with [SWARCH] if you'd like to schedule a SWARCH review to discuss the RFC.
109 | - If there are areas that you're not convinced on, tag people who you consider may know about this and ask for their input.
110 | - If you have doubts, ask your manager for help moving something forward.
111 | - As the author/s, this is _your decision_ and are empowered to choose to move forward despite dissenting comment. We're not looking for consensus driven decision making.
112 | - The success of the implementation of your proposal depends on how this decision relates to our company objectives and priorities.
113 |
--------------------------------------------------------------------------------
/.github/workflows/crystal.yml:
--------------------------------------------------------------------------------
1 | name: Crystal CI
2 | on:
3 | push:
4 | branches:
5 | - master
6 |
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 |
11 | container:
12 | image: crystallang/crystal
13 |
14 | steps:
15 | - uses: actions/checkout@v1
16 |
17 | - name: Install dependencies
18 | run: shards install
19 |
20 | - name: Run Code Format
21 | run: crystal tool format --check
22 |
23 | - name: Run Ameba Checks
24 | run: bin/ameba
25 |
26 | - name: Run Authorization Service
27 | run: crystal ./spec/support/test_server.cr &
28 |
29 | - name: Run Grants
30 | run: crystal spec spec/grants/*_spec.cr
31 |
32 | - name: Run Authly
33 | run: crystal spec spec/*_spec.cr
34 | release:
35 | runs-on: ubuntu-latest
36 | needs:
37 | - build
38 | if: ${{ success() }}
39 | steps:
40 | - name: Checkout
41 | uses: actions/checkout@v2
42 | with:
43 | fetch-depth: 0
44 |
45 | - name: Compute Release Version
46 | id: semver
47 | uses: paulhatch/semantic-version@v4.0.2
48 | with:
49 | tag_prefix: "v"
50 | major_pattern: "(MAJOR)"
51 | minor_pattern: "(MINOR)"
52 | # A string to determine the format of the version output
53 | format: "${major}.${minor}.${patch}"
54 | # If this is set to true, *every* commit will be treated as a new version.
55 | bump_each_commit: false
56 |
57 | - name: Bump Shard Version
58 | id: bump-shard
59 | uses: fjogeleit/yaml-update-action@master
60 | with:
61 | valueFile: shard.yml
62 | propertyPath: version
63 | value: ${{steps.semver.outputs.version}}
64 | commitChange: true
65 | updateFile: true
66 | targetBranch: master
67 | masterBranchName: master
68 | createPR: false
69 | branch: master
70 | message: Set shard version ${{ steps.semver.outputs.version }}
71 |
72 | - name: Create Release
73 | id: create_release
74 | uses: actions/create-release@latest
75 | env:
76 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
77 | with:
78 | tag_name: ${{steps.semver.outputs.version_tag}}
79 | release_name: Release v${{steps.semver.outputs.version}}
80 | draft: false
81 | prerelease: true
82 |
--------------------------------------------------------------------------------
/.github/workflows/pull_request.yml:
--------------------------------------------------------------------------------
1 | name: Crystal CI Pull Request
2 |
3 | on: [pull_request]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 |
9 | container:
10 | image: crystallang/crystal
11 |
12 | steps:
13 | - uses: actions/checkout@v1
14 |
15 | - name: Install dependencies
16 | run: shards install
17 |
18 | - name: Run Code Format
19 | run: crystal tool format --check
20 |
21 | - name: Run Ameba Checks
22 | run: bin/ameba
23 |
24 | - name: Run Authorization Service
25 | run: crystal ./spec/support/test_server.cr &
26 |
27 | - name: Run Grants
28 | run: crystal spec spec/grants/*_spec.cr
29 |
30 | - name: Run Authly
31 | run: crystal spec spec/*_spec.cr
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /docs/
2 | /lib/
3 | /.shards/
4 | /.vscode/
5 | *.dwarf
6 |
7 | # Libraries don't need dependency lock
8 | # Dependencies will be locked in applications that use them
9 | /shard.lock
10 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2019 Elias Perez
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Authly - Simplify Authentication for Your Application
2 |
3 | [](https://app.codacy.com/manual/eliasjpr/authly?utm_source=github.com&utm_medium=referral&utm_content=eliasjpr/authly&utm_campaign=Badge_Grade_Settings) [](https://github.com/azutoolkit/authly/actions/workflows/crystal.yml)
4 |
5 |

6 |
7 | Authly is an open-source Crystal library that helps developers integrate secure and robust authentication capabilities into their applications with ease. Supporting various OAuth2 grants and providing straightforward APIs, Authly aims to streamline the process of managing authentication in a modern software environment.
8 |
9 | ## Table of Contents
10 |
11 | - [Authly - Simplify Authentication for Your Application](#authly---simplify-authentication-for-your-application)
12 | - [Table of Contents](#table-of-contents)
13 | - [About The Project](#about-the-project)
14 | - [Getting Started](#getting-started)
15 | - [Prerequisites](#prerequisites)
16 | - [Installation](#installation)
17 | - [Usage](#usage)
18 | - [Configuration Setup](#configuration-setup)
19 | - [Custom Authorizable Client and Owner](#custom-authorizable-client-and-owner)
20 | - [Custom Client Example](#custom-client-example)
21 | - [Custom Owner Example](#custom-owner-example)
22 | - [Creating a Custom TokenStore](#creating-a-custom-tokenstore)
23 | - [Custom TokenStore Example](#custom-tokenstore-example)
24 | - [OAuth2 Grants](#oauth2-grants)
25 | - [Endpoints](#endpoints)
26 | - [Example Usage](#example-usage)
27 | - [Features](#features)
28 | - [Roadmap](#roadmap)
29 | - [Contributing](#contributing)
30 | - [License](#license)
31 | - [Contact](#contact)
32 | - [Acknowledgments](#acknowledgments)
33 |
34 | ## About The Project
35 |
36 | Authly is designed to make authentication simple for developers who want to focus on building their application rather than getting bogged down in authentication logic. Authly offers:
37 |
38 | - Support for OAuth2 grant types, including Authorization Code, Client Credentials, Password, Implicit, Device Code, and Refresh Token.
39 | - OpenID Connect support for ID Token generation.
40 | - A well-documented, intuitive API.
41 | - Built-in token management with support for introspection and revocation.
42 | - Easy-to-configure HTTP handlers for integrating authentication flows.
43 | - Support for both opaque and JWT tokens.
44 |
45 | ## Getting Started
46 |
47 | To get started with Authly, follow these steps to install and integrate it with your project.
48 |
49 | ### Prerequisites
50 |
51 | - Crystal language (v1.0 or higher)
52 | - A working development environment (Linux, macOS, or Windows)
53 |
54 | ### Installation
55 |
56 | To install Authly, add it to your `shard.yml`:
57 |
58 | ```yaml
59 | dependencies:
60 | authly:
61 | github: azutoolkit/authly
62 | ```
63 |
64 | Run `shards install` to add the dependency to your project.
65 |
66 | ## Usage
67 |
68 | ### Configuration Setup
69 |
70 | To configure Authly, you need to set up some essential configuration options for your application. These options include issuer information, secret keys, token expiration settings, and custom handlers. Here’s how you can set up the configuration:
71 |
72 | ```crystal
73 | Authly.configure do |config|
74 | config.issuer = "https://your-app.com"
75 | config.secret_key = ENV["AUTHLY_SECRET_KEY"] # Ensure you keep this key secure
76 | config.public_key = ENV["AUTHLY_PUBLIC_KEY"]
77 | config.refresh_ttl = 1.day # Token Time-to-Live in seconds
78 | config.code_ttl = 5.minutes
79 | config.access_ttl =1.hour
80 | config.owners = CustomAuthorizableOwner.new
81 | config.clients = CustomAuthorizableClient.new
82 | config.token_store = CustomTokenStore.new
83 | config.algorithm = JWT::Algorithm::HS256
84 | config.token_strategy = :jwt
85 | end
86 | ```
87 |
88 | This configuration ensures that your application has secure, well-defined settings for token management.
89 |
90 | ### Custom Authorizable Client and Owner
91 |
92 | Authly allows you to implement custom clients and owners to define how they are authorized within your application. You need to implement `AuthorizableClient` and `AuthorizableOwner` interfaces.
93 |
94 | #### Custom Client Example
95 |
96 | ```crystal
97 | class CustomAuthorizableClient
98 | include Authly::AuthorizableClient
99 |
100 | def valid_redirect?(redirect_uri : String) : Bool
101 | # Implement logic to verify if the provided redirect URI is valid
102 | valid_uris = ["https://your-app.com/callback", "https://another-allowed-url.com"]
103 | valid_uris.includes?(redirect_uri)
104 | end
105 |
106 | def authorized?(client_id : String, client_secret : String) : Bool
107 | # Implement logic to verify client credentials
108 | client_id == "expected_client_id" && client_secret == "expected_client_secret"
109 | end
110 | end
111 | ```
112 |
113 | #### Custom Owner Example
114 |
115 | ```crystal
116 | class CustomAuthorizableOwner
117 | include Authly::AuthorizableOwner
118 |
119 | def authorized?(username : String, password : String) : Bool
120 | # Implement logic to verify user credentials
121 | username == "valid_user" && password == "valid_password"
122 | end
123 |
124 | def id_token(user_data : Hash(String, String)) : String
125 | # Create an ID Token for the user
126 | "user_id_token"
127 | end
128 | end
129 | ```
130 |
131 | These implementations allow your app to customize the logic for verifying clients and owners.
132 |
133 | ### Creating a Custom TokenStore
134 |
135 | Authly comes with an in-memory token store by default, which works well for development or single-node deployments. However, for production use or when you need persistence across restarts, you can create a custom `TokenStore`.
136 |
137 | #### Custom TokenStore Example
138 |
139 | ```crystal
140 | class CustomTokenStore
141 | include Authly::TokenStore
142 |
143 | def store(token : String, data : Hash(String, String))
144 | # Store token in a database or any other persistent storage
145 | DB.exec("INSERT INTO tokens (token, data) VALUES (?, ?)", token, data.to_json)
146 | end
147 |
148 | def fetch(token : String) : Hash(String, String)?
149 | # Fetch token data from the persistent storage
150 | result = DB.query_one("SELECT data FROM tokens WHERE token = ?", token)
151 | result.not_nil! ? JSON.parse(result, Hash(String, String)) : nil
152 | end
153 |
154 | def revoke(token : String)
155 | # Revoke a token by removing it from the persistent storage
156 | DB.exec("DELETE FROM tokens WHERE token = ?", token)
157 | end
158 |
159 | def revoked?(token : String) : Bool
160 | # Check if a token has been revoked
161 | !DB.query_one("SELECT 1 FROM tokens WHERE token = ?", token).nil?
162 | end
163 |
164 | def valid?(token : String) : Bool
165 | # Implement logic to verify if the token is still valid
166 | !revoked?(token)
167 | end
168 | end
169 | ```
170 |
171 | This custom store allows for better scalability and persistence, making your authentication system robust and reliable.
172 |
173 | ### OAuth2 Grants
174 |
175 | Authly supports several OAuth2 grant types, which can be used based on your application's authentication needs:
176 |
177 | 1. **Authorization Code Grant**: This is the most secure grant type, typically used by server-side applications where the client secret can be kept confidential. It involves an intermediate authorization code that is exchanged for an access token.
178 | 2. **Implicit Grant**: This grant type is used for public clients, such as JavaScript apps, where the access token is returned directly without an intermediate authorization code.
179 | 3. **Resource Owner Credentials Grant**: Suitable for highly trusted applications, this grant type allows the use of the resource owner's username and password directly to obtain an access token.
180 | 4. **Client Credentials Grant**: Used when the client itself is the resource owner, or when accessing its own resources. This is suitable for machine-to-machine communication.
181 | 5. **Refresh Token Grant**: Allows clients to obtain a new access token without requiring user interaction, thus improving the user experience by keeping users logged in.
182 | 6. **Device Code**: Suitable for devices that have limited input capabilities. The device code flow allows users to authenticate on a separate device with a browser.
183 |
184 | Each of these grants can be accessed through the `/oauth/token` endpoint, with specific parameters to specify the grant type.
185 |
186 | Authly provides an easy way to set up an authentication service in your application. Here's how to get started with its key components:
187 |
188 | ### Endpoints
189 |
190 | Authly provides HTTP handlers to set up OAuth2 endpoints. The available endpoints include:
191 |
192 | 1. **Authorization Endpoint** (`/oauth/authorize`): Used to get authorization from the resource owner.
193 |
194 | ```crystal
195 | server = HTTP::Server.new([
196 | Authly::Handler.new,
197 | ])
198 | server.bind_tcp("127.0.0.1", 8080)
199 | server.listen
200 | ```
201 |
202 | 2. **Token Endpoint** (`/oauth/token`): Used to exchange an authorization grant for an access token.
203 | 3. **Introspection Endpoint** (`/introspect`): Allows clients to validate the token.
204 | 4. **Revoke Endpoint** (`/revoke`): Used to revoke an access or refresh token.
205 |
206 | ### Example Usage
207 |
208 | To integrate Authly into your existing application, create an instance of the server with the appropriate handlers:
209 |
210 | ```crystal
211 | require "authly"
212 |
213 | server = HTTP::Server.new([
214 | Authly::Handler.new,
215 | ])
216 | server.bind_tcp("0.0.0.0", 8080)
217 | puts "Listening on http://0.0.0.0:8080"
218 | server.listen
219 | ```
220 |
221 | Once the server is running, you can send HTTP requests to authenticate users and manage tokens.
222 |
223 | ## Features
224 |
225 | - **OAuth2 Grants**:
226 | - [x] Authorization Code Grant
227 | - [x] Implicit Grant
228 | - [x] Resource Owner Credentials Grant
229 | - [x] Client Credentials Grant
230 | - [x] Refresh Token Grant
231 | - [x] Device Code
232 | - **OpenID Connect (ID Token)**: Generate ID tokens for user identity.
233 | - **Token Introspection**: Allows clients to validate tokens.
234 | - **Token Revocation**: Easy-to-use token revocation functionality to invalidate access or refresh tokens.
235 | - **Opaque Tokens**: Support for opaque tokens in addition to JWTs.
236 | - **Configurable Handlers**: Customizable HTTP handlers to integrate authentication endpoints into your application effortlessly.
237 |
238 | ## Roadmap
239 |
240 | - [ ] Add more examples for integrating with front-end frameworks.
241 | - [ ] Support OpenID Connect for extended authentication features.
242 | - [ ] Add more customization options for token storage backends.
243 |
244 | See the [open issues](https://github.com/yourusername/authly/issues) for a list of proposed features (and known issues).
245 |
246 | ## Contributing
247 |
248 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
249 |
250 | 1. Fork the Project
251 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
252 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
253 | 4. Push to the Branch (`git push origin feature/AmazingFeature`)
254 | 5. Open a Pull Request
255 |
256 | ## License
257 |
258 | Distributed under the MIT License. See `LICENSE` for more information.
259 |
260 | ## Contact
261 |
262 | Elias J. Perez - [@eliasjpr](https://github.com/eliasjpr)
263 | Project Link: [https://github.com/yourusername/authly](https://github.com/yourusername/authly)
264 |
265 | ## Acknowledgments
266 |
267 | - [Best-README-Template](https://github.com/othneildrew/Best-README-Template)
268 | - [Readme Best Practices](https://github.com/jehna/readme-best-practices)
269 | - [Shields.io](https://shields.io) for the beautiful badges
270 | - [GitHub Pages](https://pages.github.com) for hosting project documentation
271 |
--------------------------------------------------------------------------------
/authly.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/azutoolkit/authly/e1ea6fdc81993d28c0177924345d06b699a72dce/authly.png
--------------------------------------------------------------------------------
/bin/ameba:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/azutoolkit/authly/e1ea6fdc81993d28c0177924345d06b699a72dce/bin/ameba
--------------------------------------------------------------------------------
/bin/ameba.cr:
--------------------------------------------------------------------------------
1 | # Require ameba cli which starts the inspection.
2 | require "ameba/cli"
3 |
4 | # Require ameba extensions here which are added as project dependencies.
5 | # Example:
6 | #
7 | # require "ameba-performance"
8 |
--------------------------------------------------------------------------------
/bin/authly:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/azutoolkit/authly/e1ea6fdc81993d28c0177924345d06b699a72dce/bin/authly
--------------------------------------------------------------------------------
/bin/test_server:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/azutoolkit/authly/e1ea6fdc81993d28c0177924345d06b699a72dce/bin/test_server
--------------------------------------------------------------------------------
/shard.yml:
--------------------------------------------------------------------------------
1 | name: authly
2 | version: 1.2.7
3 | authors:
4 | - Elias Perez
5 | crystal: '>=0.31.1'
6 | license: MIT
7 | targets:
8 | authly:
9 | main: src/authly.cr
10 | dependencies:
11 | jwt:
12 | github: crystal-community/jwt
13 | development_dependencies:
14 | faker:
15 | github: askn/faker
16 | ameba:
17 | github: crystal-ameba/ameba
18 |
--------------------------------------------------------------------------------
/spec/authly_spec.cr:
--------------------------------------------------------------------------------
1 | require "./spec_helper"
2 |
3 | describe Authly do
4 | client_id = "1"
5 | client_secret = "secret"
6 | state = "state"
7 | scope = "scope"
8 | redirect_uri = "https://www.example.com/callback"
9 | code_challenge = "code_challenge"
10 | code_challenge_method = "S256"
11 |
12 | describe ".access_token" do
13 | describe "openid" do
14 | it "gets id token" do
15 | openid_scope = "openid read"
16 | code = Authly::Code.new(
17 | client_id, openid_scope, redirect_uri, user_id: "username").to_s
18 |
19 | token = Authly.access_token(
20 | grant_type: "authorization_code",
21 | client_id: client_id,
22 | client_secret: client_secret,
23 | redirect_uri: redirect_uri,
24 | code: code)
25 |
26 | if id_token = token.id_token
27 | id_token_decoded = Authly.jwt_decode(id_token).first
28 |
29 | token.should be_a Authly::AccessToken
30 | id_token_decoded["user_id"].should eq "username"
31 | end
32 | end
33 | end
34 |
35 | it "returns access_token for AuthorizationCode grant" do
36 | code = Authly.code("code", client_id, redirect_uri, "read", state, code_challenge, code_challenge_method).to_s
37 | token = Authly.access_token(
38 | grant_type: "authorization_code",
39 | client_id: client_id,
40 | client_secret: client_secret,
41 | redirect_uri: redirect_uri,
42 | code: code)
43 |
44 | token.should be_a Authly::AccessToken
45 | end
46 |
47 | it "returns access_token for ClientCredentials grant" do
48 | token = Authly.access_token(
49 | grant_type: "client_credentials",
50 | client_id: client_id,
51 | client_secret: client_secret)
52 |
53 | token.should be_a Authly::AccessToken
54 | end
55 |
56 | it "returns access_token for Password grant" do
57 | username = "username"
58 | password = "password"
59 | token = Authly.access_token(
60 | grant_type: "password",
61 | client_id: client_id,
62 | client_secret: client_secret,
63 | username: username,
64 | password: password)
65 |
66 | token.should be_a Authly::AccessToken
67 | end
68 |
69 | it "returns access_token for Refresh Token grant" do
70 | a_token = Authly::AccessToken.new(client_id, scope)
71 | token = Authly.access_token(
72 | grant_type: "refresh_token",
73 | client_id: client_id,
74 | client_secret: client_secret,
75 | refresh_token: a_token.refresh_token.to_s)
76 |
77 | token.should be_a Authly::AccessToken
78 | end
79 | end
80 |
81 | describe ".revoke" do
82 | it "revokes token" do
83 | a_token = Authly::AccessToken.new(client_id, scope).access_token
84 | Authly.revoke(a_token)
85 | Authly.revoked?(a_token).should eq true
86 | end
87 |
88 | it "does not revoke token" do
89 | a_token = Authly::AccessToken.new(client_id, scope).access_token
90 | Authly.revoked?(a_token).should eq false
91 | end
92 |
93 | it "raises error for invalid jwt token" do
94 | expect_raises JWT::DecodeError do
95 | Authly.revoked?("invalid_jti")
96 | end
97 | end
98 | end
99 |
100 | describe ".valid?" do
101 | it "returns true" do
102 | a_token = Authly::AccessToken.new(client_id, scope)
103 | token = a_token.access_token
104 | Authly.valid?(token).should eq true
105 | end
106 |
107 | it "returns false" do
108 | a_token = Authly::AccessToken.new(client_id, scope)
109 | token = a_token.access_token
110 |
111 | Authly.revoke(token)
112 |
113 | Authly.valid?(token).should eq false
114 | end
115 |
116 | it "returns false" do
117 | a_token = Authly::AccessToken.new(client_id, scope)
118 | token = a_token.access_token
119 | Authly.revoke(token)
120 | Authly.valid?(token).should eq false
121 | end
122 | end
123 |
124 | describe ".introspect" do
125 | it "returns active token" do
126 | a_token = Authly::AccessToken.new(client_id, scope)
127 | expected_token = Authly.jwt_decode(a_token.access_token).first
128 | token = Authly.introspect(a_token.access_token)
129 |
130 | token.should eq({
131 | "active" => true,
132 | "scope" => scope,
133 | "cid" => client_id,
134 | "exp" => a_token.expires_in,
135 | "sub" => expected_token["sub"],
136 | })
137 | end
138 |
139 | it "returns inactive token" do
140 | token = Authly.introspect("invalid_token")
141 |
142 | token.should eq({"active" => false})
143 | end
144 |
145 | it "returns inactive token" do
146 | a_token = Authly::AccessToken.new(client_id, scope)
147 | token = Authly.introspect(a_token.to_s + "invalid")
148 |
149 | token.should eq({"active" => false})
150 | end
151 | end
152 |
153 | describe ".code" do
154 | it "returns an temporary code" do
155 | code = Authly.code("code", client_id, redirect_uri, scope)
156 | code.should be_a Authly::Code
157 | end
158 |
159 | it "raises invalid redirect uri" do
160 | expect_raises Authly::Error, Authly::ERROR_MSG[:invalid_redirect_uri] do
161 | Authly.code("code", client_id, "", scope, state)
162 | end
163 | end
164 | end
165 | end
166 |
--------------------------------------------------------------------------------
/spec/code_challenge_builder_spec.cr:
--------------------------------------------------------------------------------
1 | require "./spec_helper"
2 |
3 | module Authly
4 | describe CodeChallengeBuilder do
5 | code = Random::Secure.hex(32)
6 | code_verifier = Base64.urlsafe_encode(code).gsub(/{\+|\=|\/}/, "")
7 | code_challenge = Digest::SHA256.base64digest(code_verifier)
8 |
9 | describe "Plain Code Challenge" do
10 | code_challenge_method = "plain"
11 | challenge = CodeChallengeBuilder.build(code_challenge, code_challenge_method)
12 |
13 | it "is a valid code challenge" do
14 | challenge.should be_a CodeChallengeBuilder::Plain
15 | challenge.valid?(code_challenge).should be_true
16 | end
17 | end
18 |
19 | describe "S256 Code Challenge" do
20 | code_challenge_method = "S256"
21 | challenge = CodeChallengeBuilder.build(code_challenge, code_challenge_method)
22 |
23 | it "is a valid code challenge" do
24 | challenge.should be_a CodeChallengeBuilder::S256
25 | challenge.valid?(code_verifier).should be_true
26 | end
27 | end
28 |
29 | describe "S256 Code Challenge" do
30 | challenge = CodeChallengeBuilder.build
31 |
32 | it "is a valid code challenge" do
33 | challenge.should be_a CodeChallengeBuilder::S256
34 | challenge.valid?("").should be_false
35 | end
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/spec/code_spec.cr:
--------------------------------------------------------------------------------
1 | require "./spec_helper"
2 |
3 | module Authly
4 | describe Code do
5 | context "when request is authorized" do
6 | it "creates a temporary code request object" do
7 | auth_code = Code.new(
8 | "challenge",
9 | "S256",
10 | "read",
11 | "user_id",
12 | "redirect_uri",
13 | "client_id"
14 | )
15 |
16 | auth_code.should be_a Code
17 | auth_code.code.should_not be_empty
18 | end
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/spec/grant_spec.cr:
--------------------------------------------------------------------------------
1 | # Spec file for testing the Grant class and its compliance with OAuth2 specifications
2 | require "./spec_helper"
3 |
4 | module Authly
5 | describe Grant do
6 | client_id = "1"
7 | client_secret = "secret"
8 | username = "username"
9 | password = "password"
10 | redirect_uri = "https://www.example.com/callback"
11 | refresh_token = Authly.jwt_encode({
12 | "jti" => Random::Secure.hex(32),
13 | "sub" => client_id,
14 | "name" => "refresh token",
15 | "iat" => Time.utc.to_unix,
16 | "iss" => Authly.config.issuer,
17 | "exp" => Authly.config.refresh_ttl.from_now.to_unix,
18 | })
19 | authorization_code = Authly::Code.new(client_id, "read", redirect_uri, "", "", username).to_s
20 |
21 | it "creates an access token with valid client credentials grant" do
22 | grant = Grant.new("client_credentials", client_id: client_id, client_secret: client_secret)
23 | token = grant.token
24 |
25 | token.client_id.should eq client_id
26 | token.scope.should eq ""
27 | end
28 |
29 | it "creates an access token with valid password grant" do
30 | grant = Grant.new("password",
31 | client_id: client_id,
32 | client_secret: client_secret,
33 | username: username,
34 | password: password)
35 |
36 | token = grant.token
37 |
38 | token.client_id.should eq client_id
39 | token.scope.should eq ""
40 | end
41 |
42 | it "creates an access token with valid authorization code grant" do
43 | grant = Grant.new("authorization_code",
44 | client_id: client_id,
45 | client_secret: client_secret,
46 | redirect_uri: redirect_uri,
47 | code: authorization_code)
48 |
49 | token = grant.token
50 |
51 | token.client_id.should eq client_id
52 | token.scope.should eq "read"
53 | end
54 |
55 | it "creates an access token with valid refresh token grant" do
56 | grant = Grant.new("refresh_token",
57 | client_id: client_id,
58 | client_secret: client_secret,
59 | refresh_token: refresh_token,
60 | code: authorization_code)
61 |
62 | token = grant.token
63 |
64 | token.client_id.should eq client_id
65 | token.scope.should eq "read"
66 | end
67 |
68 | it "raises error for unsupported grant type" do
69 | expect_raises(Error) { Grant.new("unsupported_grant_type", client_id: client_id, client_secret: client_secret) }
70 | end
71 |
72 | it "validates scope for access token" do
73 | grant = Grant.new("authorization_code",
74 | client_id: client_id,
75 | client_secret: client_secret,
76 | redirect_uri: redirect_uri,
77 | code: authorization_code)
78 |
79 | token = grant.token
80 |
81 | token.scope.should eq "read"
82 | end
83 |
84 | it "raises error for invalid scope" do
85 | scope = "pluto"
86 | invalid_scope = "mars"
87 | Authly.clients << Authly::Client.new("new_client", "secret", "https://www.example.com/callback", "2", scope)
88 | authorization_code = Authly::Code.new("2", invalid_scope, redirect_uri, "", "", username).to_s
89 |
90 | grant = Grant.new("authorization_code",
91 | client_id: "2",
92 | client_secret: client_secret,
93 | redirect_uri: redirect_uri,
94 | code: authorization_code)
95 |
96 | expect_raises(Error) { grant.token }
97 | end
98 | end
99 | end
100 |
--------------------------------------------------------------------------------
/spec/grants/authorization_code_spec.cr:
--------------------------------------------------------------------------------
1 | require "../spec_helper"
2 |
3 | module Authly
4 | describe AuthorizationCode do
5 | code_data = {
6 | "client_id" => "1",
7 | "secret" => "secret",
8 | "redirect_uri" => "https://www.example.com/callback",
9 | "scope" => "read",
10 | "state" => "state",
11 | }
12 |
13 | describe "#authorize" do
14 | client_id, secret, uri, scope = code_data.values
15 | code_verifier = Faker::Internet.password(43, 128)
16 |
17 | describe "code challenge" do
18 | it "peforms plain code challenge authorization" do
19 | code_challenge = code_verifier
20 | code = Code.new(code_challenge, "plain", scope, "user_id", uri, client_id).to_s
21 | auth_code = AuthorizationCode.new(client_id, secret, uri, code, code_verifier)
22 | auth_code.authorized?.should be_truthy
23 | end
24 |
25 | it "peforms S256 code challenge authorization" do
26 | code_challenge = Digest::SHA256.base64digest(code_verifier)
27 | code = Code.new(code_challenge, "s256", scope, "user_id", uri, client_id).to_s
28 | authorization_code = AuthorizationCode.new(client_id, secret, uri, code, code_verifier)
29 |
30 | authorization_code.authorized?.should be_truthy
31 | end
32 | end
33 |
34 | it "returns true" do
35 | code = Code.new(client_id, scope, uri).to_s
36 | authorization_code = AuthorizationCode.new(client_id, secret, uri, code)
37 | authorization_code.authorized?.should be_truthy
38 | end
39 |
40 | it "raises error for invalid client credentials" do
41 | code = Code.new(client_id, scope, uri).to_s
42 | authorization_code = AuthorizationCode.new(client_id, "invalid", uri, code)
43 |
44 | expect_raises Error, ERROR_MSG[:unauthorized_client] do
45 | authorization_code.authorized?
46 | end
47 | end
48 |
49 | it "raises Error for client id" do
50 | code = Code.new(client_id, scope, uri).to_s
51 | authorization_code = AuthorizationCode.new("invalid_client", "invalid_client_secret", uri, code)
52 |
53 | expect_raises Error, ERROR_MSG[:invalid_redirect_uri] do
54 | authorization_code.authorized?
55 | end
56 | end
57 |
58 | it "raises Error for redirect uri" do
59 | code = Code.new(client_id, scope, uri).to_s
60 | authorization_code = AuthorizationCode.new(client_id, secret, "", code)
61 |
62 | expect_raises Error, ERROR_MSG[:invalid_redirect_uri] do
63 | authorization_code.authorized?
64 | end
65 | end
66 | end
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/spec/grants/client_credentials_spec.cr:
--------------------------------------------------------------------------------
1 | require "../spec_helper"
2 |
3 | module Authly
4 | describe ClientCredentials do
5 | cid, secret = "1", "secret"
6 |
7 | it "returns AccessToken" do
8 | client_credentials = ClientCredentials.new(
9 | client_id: cid, client_secret: secret
10 | )
11 | client_credentials.authorized?.should be_truthy
12 | end
13 |
14 | it "raises error for invalid client credentials" do
15 | client_credentials = ClientCredentials.new(
16 | client_id: cid, client_secret: "invalid"
17 | )
18 | expect_raises Error, ERROR_MSG[:unauthorized_client] do
19 | client_credentials.authorized?
20 | end
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/spec/grants/password_spec.cr:
--------------------------------------------------------------------------------
1 | require "../spec_helper"
2 |
3 | module Authly
4 | cid, secret, username, password = "1", "secret", "username", "password"
5 |
6 | describe Password do
7 | password_authorization = Password.new(
8 | client_id: cid, client_secret: secret, username: username, password: password
9 | )
10 |
11 | it "returns AccessToken" do
12 | password_authorization.authorized?.should be_truthy
13 | end
14 |
15 | it "raises error for invalid client credentials" do
16 | invalid_auth = Password.new(
17 | client_id: cid, client_secret: "bad secret", username: username, password: password
18 | )
19 |
20 | expect_raises Error, ERROR_MSG[:unauthorized_client] do
21 | invalid_auth.authorized?
22 | end
23 | end
24 |
25 | it "raises error for invalid owner credentials" do
26 | invalid_owner = Password.new(
27 | client_id: cid, client_secret: secret, username: username, password: "bad password"
28 | )
29 |
30 | expect_raises Error, ERROR_MSG[:owner_credentials] do
31 | invalid_owner.authorized?
32 | end
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/spec/grants/refresh_token_spec.cr:
--------------------------------------------------------------------------------
1 | require "../spec_helper"
2 |
3 | module Authly
4 | describe RefreshToken do
5 | client_id, secret, scope = "1", "secret", "read write"
6 |
7 | it "returns RefreshToken" do
8 | token = AccessToken.new(client_id, scope).refresh_token
9 | refresh_token = RefreshToken.new(
10 | client_id: client_id, client_secret: secret, refresh_token: token,
11 | )
12 | refresh_token.authorized?.should be_truthy
13 | end
14 |
15 | it "raises error for invalid client credentials" do
16 | token = AccessToken.new(client_id, scope).refresh_token
17 |
18 | refresh_token = RefreshToken.new(
19 | client_id: client_id, client_secret: "BAD", refresh_token: token
20 | )
21 |
22 | expect_raises Error, ERROR_MSG[:unauthorized_client] do
23 | refresh_token.authorized?
24 | end
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/spec/spec_helper.cr:
--------------------------------------------------------------------------------
1 | require "spec"
2 | require "digest"
3 | require "base64"
4 | require "faker"
5 | require "../src/authly"
6 | require "./support/settings"
7 |
--------------------------------------------------------------------------------
/spec/support/handlers_spec.cr:
--------------------------------------------------------------------------------
1 | # Spec tests for Authly's OAuth Handlers
2 | require "../spec_helper"
3 | require "http/server"
4 |
5 | module Authly
6 | describe "AuthorizationHandler" do
7 | it "returns authorization code with valid client_id and redirect_uri after user consent and includes state" do
8 | state = "test_state"
9 | # Initial Authorize Request
10 | HTTP::Client.get("#{BASE_URI}/oauth/authorize?client_id=1&redirect_uri=https://www.example.com/callback&response_type=code&state=#{state}")
11 |
12 | # Consent Request
13 | response = HTTP::Client.get("#{BASE_URI}/oauth/authorize?client_id=1&redirect_uri=https://www.example.com/callback&response_type=code&state=#{state}&consent=approved")
14 |
15 | response.status_code.should eq 302
16 | response.headers["Location"].should_not be_nil
17 | response.headers["Location"].should contain(URI.encode_path("code="))
18 | response.headers["Location"].should contain(URI.encode_path("state=#{state}"))
19 | end
20 |
21 | it "renders consent page if user consent is not provided" do
22 | state = "test_state"
23 | response = HTTP::Client.get("#{BASE_URI}/oauth/authorize?client_id=1&redirect_uri=https://www.example.com/callback&response_type=code&state=#{state}")
24 |
25 | response.status_code.should eq 200
26 | response.body.should contain("Authorization Request")
27 | response.body.should contain("Approve")
28 | response.body.should contain("Deny")
29 | response.body.should contain(state)
30 | end
31 |
32 | it "returns 401 for invalid client_id or redirect_uri" do
33 | state = "test_state"
34 | response = HTTP::Client.get("#{BASE_URI}/oauth/authorize?client_id=invalid&redirect_uri=invalid&state=#{state}&consent=approved")
35 | response.status_code.should eq 401
36 | response.body.should eq "This client is not authorized to use the requested grant type"
37 | end
38 |
39 | it "returns 400 for invalid state parameter" do
40 | state = "invalid_state"
41 | response = HTTP::Client.get("#{BASE_URI}/oauth/authorize?client_id=1&redirect_uri=https://www.example.com/callback&response_type=code&state=#{state}&consent=approved")
42 |
43 | response.status_code.should eq 400
44 | response.body.should eq "Invalid state parameter"
45 | end
46 | end
47 |
48 | describe "TokenHandler" do
49 | it "returns access token for valid authorization_code grant" do
50 | code = Authly.code("code", "1", "https://www.example.com/callback", "read").to_s
51 | response = HTTP::Client.post("#{BASE_URI}/oauth/token", form: {
52 | "grant_type" => "authorization_code",
53 | "client_id" => "1",
54 | "client_secret" => "secret",
55 | "redirect_uri" => "https://www.example.com/callback",
56 | "code" => code,
57 | })
58 | response.status_code.should eq 200
59 | body = JSON.parse(response.body)
60 | body["access_token"].should_not be_nil
61 | end
62 |
63 | it "returns 400 for unsupported grant type" do
64 | response = HTTP::Client.post("#{BASE_URI}/oauth/token", form: {"grant_type" => "invalid_grant"})
65 | response.status_code.should eq 400
66 | response.body.should eq "Invalid or unknown grant type"
67 | end
68 | end
69 |
70 | describe "IntrospectHandler" do
71 | it "returns introspection result for valid token" do
72 | code = Authly.code("code", "1", "https://www.example.com/callback", "read").to_s
73 | token = Grant.new(
74 | grant_type: "authorization_code", client_id: "1", client_secret: "secret",
75 | redirect_uri: "https://www.example.com/callback", code: code).token
76 |
77 | response = HTTP::Client.post("#{BASE_URI}/introspect", form: {"token" => token.access_token})
78 | response.status_code.should eq 200
79 | body = JSON.parse(response.body)
80 | body.should eq({
81 | "active" => true,
82 | "scope" => token.scope,
83 | "cid" => token.client_id,
84 | "exp" => token.expires_in,
85 | "sub" => token.sub,
86 | })
87 | end
88 |
89 | it "returns 400 for missing token parameter" do
90 | response = HTTP::Client.post("#{BASE_URI}/introspect")
91 | response.status_code.should eq 400
92 | response.body.should eq %(Missing param name: "token")
93 | end
94 | end
95 |
96 | describe "RevokeHandler" do
97 | it "returns success message for valid token revocation" do
98 | access_token = Authly::AccessToken.new("1", "read").access_token
99 | response = HTTP::Client.post("#{BASE_URI}/revoke", form: {"token" => access_token})
100 | response.status_code.should eq 200
101 | response.body.should eq "Token revoked successfully"
102 | end
103 |
104 | it "returns 400 for missing token parameter" do
105 | response = HTTP::Client.post("#{BASE_URI}/revoke")
106 | response.status_code.should eq 400
107 | response.body.should eq %(Missing param name: "token")
108 | end
109 | end
110 | end
111 |
--------------------------------------------------------------------------------
/spec/support/settings.cr:
--------------------------------------------------------------------------------
1 | secret_key = "4bce37fbb1542a68dddba2da22635beca9d814cb3424c461fcc8876904ad39c1"
2 | BASE_URI = "http://0.0.0.0:4000"
3 | STATE_STORE = Authly::InMemoryStateStore.new
4 |
5 | Authly.configure do |config|
6 | config.secret_key = secret_key
7 | config.public_key = secret_key
8 | config.state_store = STATE_STORE
9 | end
10 |
11 | Authly.clients << Authly::Client.new("example", "secret", "https://www.example.com/callback", "1")
12 | Authly.owners << Authly::Owner.new("username", "password")
13 |
14 | OAUTH_HANDLER = Authly::Handler.new
15 |
--------------------------------------------------------------------------------
/spec/support/test_server.cr:
--------------------------------------------------------------------------------
1 | require "http/server"
2 | require "../../src/authly"
3 | require "./settings"
4 |
5 | server = HTTP::Server.new([
6 | Authly::Handler.new,
7 | ])
8 | server.bind_tcp "0.0.0.0", 4000
9 | puts "Listening on http://0.0.0.0:4000"
10 |
11 | server.listen
12 |
--------------------------------------------------------------------------------
/src/authly.cr:
--------------------------------------------------------------------------------
1 | require "jwt"
2 | require "json"
3 | require "./authly/authorizable_owner"
4 | require "./authly/authorizable_client"
5 | require "./authly/token_store"
6 | require "./authly/**"
7 | require "log"
8 |
9 | module Authly
10 | CONFIG = Configuration.new
11 |
12 | def self.configure(&)
13 | yield CONFIG
14 | end
15 |
16 | def self.config
17 | CONFIG
18 | end
19 |
20 | def self.clients
21 | CONFIG.clients
22 | end
23 |
24 | def self.owners
25 | CONFIG.owners
26 | end
27 |
28 | def self.code(response_type, *args)
29 | ResponseType.new(response_type, *args).decode
30 | end
31 |
32 | def self.access_token(grant_type, **args)
33 | Grant.new(grant_type, **args).token
34 | end
35 |
36 | def self.jwt_encode(payload)
37 | JWT.encode(payload, config.secret_key, config.algorithm)
38 | end
39 |
40 | def self.jwt_decode(token, secret_key = config.public_key)
41 | JWT.decode(token, secret_key, config.algorithm, iss: config.issuer)
42 | end
43 |
44 | def self.revoke(token)
45 | TokenManager.instance.revoke(token)
46 | end
47 |
48 | def self.revoked?(token)
49 | TokenManager.instance.revoked?(token)
50 | end
51 |
52 | def self.valid?(token)
53 | TokenManager.instance.valid?(token)
54 | end
55 |
56 | def self.introspect(token : String)
57 | TokenManager.instance.introspect(token)
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/src/authly/access_token.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | struct AccessToken
3 | include JSON::Serializable
4 | ACCESS_TTL = Authly.config.access_ttl
5 | REFRESH_TTL = Authly.config.refresh_ttl
6 |
7 | # The JWT ID (jti), used to track and revoke individual tokens
8 | getter sub : String = Random::Secure.hex(32)
9 | getter jti : String
10 | getter access_token : String
11 | getter token_type : String = "Bearer"
12 | getter scope : String
13 | getter expires_in : Int64 = ACCESS_TTL.from_now.to_unix
14 | getter? revoked : Bool = false
15 | @[JSON::Field(emit_null: false)]
16 | getter refresh_token : String
17 | @[JSON::Field(emit_null: false)]
18 | getter id_token : String? = nil
19 | @[JSON::Field(ignore: true)]
20 | getter client_id : String
21 |
22 | def self.jwt(client_id, scope, id_token)
23 | new(client_id, scope, id_token).to_json
24 | end
25 |
26 | def initialize(@client_id : String, @scope : String, @id_token : String? = nil)
27 | @jti = Random::Secure.hex(32)
28 | @access_token = generate_token
29 | @refresh_token = refresh_token
30 | end
31 |
32 | private def generate_token
33 | Authly.jwt_encode({
34 | "sub" => sub,
35 | "iss" => Authly.config.issuer,
36 | "cid" => @client_id,
37 | "iat" => Time.utc.to_unix,
38 | "exp" => ACCESS_TTL.from_now.to_unix,
39 | "scope" => @scope,
40 | "jti" => @jti, # Include the jti in the token claims
41 | })
42 | end
43 |
44 | def refresh_token
45 | Authly.jwt_encode({
46 | "jti" => Random::Secure.hex(32),
47 | "sub" => @client_id,
48 | "name" => "refresh token",
49 | "iat" => Time.utc.to_unix,
50 | "iss" => Authly.config.issuer,
51 | "exp" => REFRESH_TTL.from_now.to_unix,
52 | })
53 | end
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/src/authly/authorizable_client.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | module AuthorizableOwner
3 | abstract def authorized?(username : String, password : String) : Bool
4 | abstract def id_token(user_id : String) : Hash(String, String | Int64)
5 | end
6 |
7 | module AuthorizableClient
8 | abstract def valid_redirect?(client_id : String, redirect_uri : String) : Bool
9 | abstract def authorized?(client_id : String, client_secret : String)
10 | abstract def allowed_scopes?(client_id : String, scopes : String) : Bool
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/src/authly/authorizable_owner.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | module AuthorizableOwner
3 | abstract def authorized?(username : String, password : String) : Bool
4 | abstract def id_token(user_id : String) : Hash(String, String | Int64)
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/src/authly/client.cr:
--------------------------------------------------------------------------------
1 | require "uuid"
2 |
3 | module Authly
4 | struct Client
5 | getter name : String
6 | getter id : String
7 | getter secret : String
8 | getter redirect_uri : String
9 | getter scopes : String
10 |
11 | def initialize(@name, @secret, @redirect_uri, @id, @scopes = "read")
12 | end
13 | end
14 |
15 | class Clients
16 | include AuthorizableClient
17 | include Enumerable(Authly::Client)
18 |
19 | def initialize
20 | @clients = [] of Client
21 | end
22 |
23 | def <<(client : Client)
24 | @clients << client
25 | end
26 |
27 | def valid_redirect?(client_id, redirect_uri) : Bool
28 | any? do |client|
29 | client.id == client_id && client.redirect_uri == redirect_uri
30 | end
31 | end
32 |
33 | def authorized?(client_id, client_secret)
34 | any? do |client|
35 | client.id == client_id && client.secret == client_secret
36 | end
37 | end
38 |
39 | def authorized?(client_id, secret, redirect_uri, code, verifier : String? = nil)
40 | any? do |client|
41 | client.id == client_id && client.secret == secret && redirect_uri == redirect_uri
42 | end
43 | end
44 |
45 | def each(& : Client -> _)
46 | @clients.each { |client| yield client }
47 | end
48 |
49 | def allowed_scopes?(client_id, scopes) : Bool
50 | the_client = self.find! { |client| client.id == client_id }
51 | return false unless the_client
52 |
53 | the_client.scopes.split(" ").all? do |scope|
54 | scopes.split(" ").includes?(scope)
55 | end
56 | rescue
57 | false
58 | end
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/src/authly/code.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | struct Code
3 | include JSON::Serializable
4 | CODE_TTL = Authly.config.code_ttl
5 | ISSUER = Authly.config.issuer
6 |
7 | getter code : String = Random::Secure.hex(16),
8 | client_id : String,
9 | scope : String,
10 | redirect_uri : String,
11 | challenge = "",
12 | method = "",
13 | user_id = ""
14 |
15 | def initialize(
16 | @client_id,
17 | @scope,
18 | @redirect_uri,
19 | @challenge = "",
20 | @method = "",
21 | @user_id = ""
22 | )
23 | end
24 |
25 | def jwt
26 | Authly.jwt_encode({
27 | "jti" => Random::Secure.hex(32),
28 | "code" => code,
29 | "challenge" => challenge,
30 | "method" => method,
31 | "scope" => scope,
32 | "user_id" => user_id,
33 | "redirect_uri" => redirect_uri,
34 | "iat" => Time.utc.to_unix,
35 | "iss" => ISSUER,
36 | "exp" => CODE_TTL.from_now.to_unix,
37 | })
38 | end
39 |
40 | def to_s
41 | jwt
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/src/authly/code_challenge_builder.cr:
--------------------------------------------------------------------------------
1 | require "digest/sha256"
2 |
3 | module Authly
4 | alias CodeChallenge = CodeChallengeBuilder::Plain | CodeChallengeBuilder::S256
5 |
6 | module CodeChallengeBuilder
7 | record Plain, code : String do
8 | def valid?(code_verifier)
9 | code == code_verifier
10 | end
11 | end
12 |
13 | record S256, code : String do
14 | def valid?(code_verifier)
15 | code == Digest::SHA256.base64digest(code_verifier)
16 | end
17 | end
18 |
19 | def self.build(challenge : String = "", method : String = "S256")
20 | case method
21 | when "plain"
22 | Plain.new(challenge)
23 | when "S256"
24 | S256.new(challenge)
25 | else
26 | raise ArgumentError.new("Unsupported code challenge method: #{method}. Only 'plain' and 'S256' are supported.")
27 | end
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/src/authly/configuration.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | class Configuration
3 | property issuer : String = "The Authority Server Provider"
4 | property secret_key : String = Random::Secure.hex(16)
5 | property public_key : String = Random::Secure.hex(16)
6 | property refresh_ttl : Time::Span = 1.day
7 | property code_ttl : Time::Span = 5.minutes
8 | property access_ttl : Time::Span = 1.hour
9 | property owners : AuthorizableOwner = Owners.new
10 | property clients : AuthorizableClient = Clients.new
11 | property token_store : TokenStore = InMemoryStore.new
12 | property algorithm : JWT::Algorithm = JWT::Algorithm::HS256
13 | property token_strategy : Symbol = :jwt
14 | property state_store : StateStore = InMemoryStateStore.new
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/src/authly/error.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | ERROR_MSG = {
3 | invalid_redirect_uri: "Invalid redirect uri",
4 | invalid_state: "Invalid state",
5 | invalid_scope: "Invalid scope value in the request",
6 | invalid_client: "Client authentication failed, such as if the request contains an invalid client ID or secret.",
7 | invalid_request: "The request is missing a parameter so the server can’t proceed with the request",
8 | invalid_grant: "The authorization code is invalid or expired.",
9 | invalid_response_type: "The response type is invalid.",
10 | owner_credentials: "Invalid owner credentials",
11 | unauthorized_client: "This client is not authorized to use the requested grant type",
12 | unsupported_grant_type: "Invalid or unknown grant type",
13 | access_denied: "The user or authorization server denied the request",
14 | unsupported_token_type: "The authorization server does not support the presented token type",
15 | invalid_token: "The token is invalid or expired",
16 | }
17 |
18 | class Error(Code) < Exception
19 | def self.invalid_token
20 | raise Error(400).new(:invalid_token)
21 | end
22 |
23 | def self.unsupported_token_type
24 | raise Error(400).new(:unsupported_token_type)
25 | end
26 |
27 | def self.owner_credentials
28 | raise Error(400).new(:owner_credentials)
29 | end
30 |
31 | def self.unsupported_grant_type
32 | raise Error(400).new(:unsupported_grant_type)
33 | end
34 |
35 | def self.unauthorized_client
36 | raise Error(401).new(:unauthorized_client)
37 | end
38 |
39 | def self.invalid_redirect_uri
40 | raise Error(400).new(:invalid_redirect_uri)
41 | end
42 |
43 | def self.invalid_state
44 | raise Error(400).new(:invalid_state)
45 | end
46 |
47 | def self.invalid_grant
48 | raise Error(400).new(:invalid_grant)
49 | end
50 |
51 | def self.invalid_scope
52 | raise Error(400).new(:invalid_scope)
53 | end
54 |
55 | def self.invalid_response_type
56 | raise Error(400).new(:invalid_response_type)
57 | end
58 |
59 | def self.bad_request(type : Symbol)
60 | raise Error(400).new(type)
61 | end
62 |
63 | getter code : Int32 = Code
64 | getter type : Symbol
65 |
66 | def initialize(@type)
67 | super "#{ERROR_MSG[@type]}"
68 | end
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/src/authly/grant.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | module GrantStrategy
3 | abstract def authorized? : Bool
4 | end
5 |
6 | class GrantFactory
7 | def self.create(grant_type : String, **args) : GrantStrategy
8 | case grant_type
9 | when "authorization_code"
10 | AuthorizationCode.new(
11 | client_id: args[:client_id],
12 | client_secret: args[:client_secret],
13 | redirect_uri: args.fetch(:redirect_uri, ""),
14 | challenge: args.fetch(:challenge, ""),
15 | method: args.fetch(:method, ""),
16 | verifier: args.fetch(:verifier, "")
17 | )
18 | when "client_credentials"
19 | ClientCredentials.new(client_id: args[:client_id], client_secret: args[:client_secret])
20 | when "password"
21 | Password.new(
22 | client_id: args[:client_id],
23 | client_secret: args[:client_secret],
24 | username: args.fetch(:username, ""),
25 | password: args.fetch(:password, "")
26 | )
27 | when "refresh_token"
28 | RefreshToken.new(
29 | client_id: args["client_id"],
30 | client_secret: args["client_secret"],
31 | refresh_token: args.fetch("refresh_token", "")
32 | )
33 | else
34 | raise Error.unsupported_grant_type
35 | end
36 | end
37 | end
38 |
39 | class Grant
40 | @grant_strategy : GrantStrategy
41 | property code : String
42 | @client_id : String
43 | @token_manager : TokenManager = TokenManager.instance
44 | @refresh_token : String
45 |
46 | def initialize(grant_type : String, **args)
47 | @grant_strategy = GrantFactory.create(grant_type, **args)
48 | @refresh_token = args.fetch(:refresh_token, "")
49 | @client_id = args.fetch(:client_id, "")
50 | @code = args.fetch(:code, "")
51 | end
52 |
53 | def token : AccessToken
54 | validate_scope!
55 | authorized?
56 |
57 | token = access_token
58 | revoke_old_refresh_token(token.access_token)
59 | token
60 | end
61 |
62 | def authorized?
63 | @grant_strategy.authorized?
64 | end
65 |
66 | def refresh_token_grant
67 | RefreshToken.new(client_id, client_secret, refresh_token)
68 | end
69 |
70 | private def access_token
71 | AccessToken.new(@grant_strategy.client_id, scope, generate_id_token)
72 | end
73 |
74 | private def generate_id_token
75 | if scope.includes? "openid"
76 | user_id = auth_code["user_id"].as_s
77 | payload = Authly.owners.id_token(user_id)
78 | payload["iss"] = Authly.config.issuer
79 | payload["aud"] = @client_id
80 | Authly.jwt_encode(payload)
81 | end
82 | end
83 |
84 | private def auth_code
85 | Authly.jwt_decode(@code).first
86 | end
87 |
88 | private def scope : String
89 | return "" if @code.empty?
90 | auth_code["scope"].as_s
91 | end
92 |
93 | private def validate_scope!
94 | return if scope.empty?
95 | unless Authly.clients.allowed_scopes?(@client_id, scope)
96 | raise Error.invalid_scope
97 | end
98 | end
99 |
100 | private def revoke_old_refresh_token(token : String)
101 | if @grant_strategy.is_a?(RefreshToken)
102 | @token_manager.revoke(@refresh_token)
103 | end
104 | end
105 | end
106 | end
107 |
--------------------------------------------------------------------------------
/src/authly/grants/authorization_code.cr:
--------------------------------------------------------------------------------
1 | require "uri"
2 |
3 | module Authly
4 | class AuthorizationCode
5 | include GrantStrategy
6 |
7 | getter client_id : String,
8 | client_secret : String,
9 | redirect_uri : String,
10 | verifier : String,
11 | challenge : String,
12 | method : String
13 |
14 | def initialize(
15 | @client_id, @client_secret, @redirect_uri, @challenge = "", @method = "", @verifier = ""
16 | )
17 | end
18 |
19 | def authorized? : Bool
20 | validate!
21 | verify_challenge!
22 | true
23 | end
24 |
25 | private def validate!
26 | raise Error.invalid_redirect_uri unless valid_redirect?
27 | raise Error.unauthorized_client unless client_authorized?
28 | end
29 |
30 | private def verify_challenge!
31 | return if verifier.empty?
32 | raise Error.unauthorized_client unless code_challenge.valid?(verifier)
33 | end
34 |
35 | private def code_challenge
36 | CodeChallengeBuilder.build(challenge, method)
37 | end
38 |
39 | private def valid_redirect?
40 | Authly.clients.valid_redirect?(client_id, redirect_uri)
41 | end
42 |
43 | private def client_authorized?
44 | Authly.clients.authorized?(client_id, client_secret)
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/src/authly/grants/client_credentials.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | class ClientCredentials
3 | include GrantStrategy
4 | getter client_id : String, client_secret : String
5 |
6 | def initialize(@client_id, @client_secret)
7 | end
8 |
9 | def authorized? : Bool
10 | raise Error.unauthorized_client unless client_authorized?
11 | true
12 | end
13 |
14 | private def client_authorized?
15 | Authly.clients.authorized?(client_id, client_secret)
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/src/authly/grants/password.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | class Password
3 | include GrantStrategy
4 | getter client_id : String,
5 | client_secret : String,
6 | username : String,
7 | password : String
8 |
9 | def initialize(@client_id, @client_secret, @username, @password)
10 | end
11 |
12 | def authorized? : Bool
13 | raise Error.unauthorized_client unless client_authorized?
14 | raise Error.owner_credentials unless owner_authorized?
15 | true
16 | end
17 |
18 | private def client_authorized?
19 | Authly.clients.authorized?(client_id, client_secret)
20 | end
21 |
22 | private def owner_authorized?
23 | Authly.owners.authorized?(username, password)
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/src/authly/grants/refresh_token.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | class RefreshToken
3 | include GrantStrategy
4 | getter client_id : String, client_secret : String, refresh_token : String
5 |
6 | def initialize(@client_id, @client_secret, @refresh_token)
7 | end
8 |
9 | def authorized? : Bool
10 | validate_code!
11 | raise Error.unauthorized_client unless client_authorized?
12 | true
13 | end
14 |
15 | private def validate_code!
16 | Authly.jwt_decode(refresh_token)
17 | rescue e
18 | raise Error.invalid_grant
19 | end
20 |
21 | private def client_authorized?
22 | Authly.clients.authorized?(client_id, client_secret)
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/src/authly/handler.cr:
--------------------------------------------------------------------------------
1 | require "http/server/handler"
2 | require "./handlers/*"
3 |
4 | module Authly
5 | class Handler
6 | include HTTP::Handler
7 |
8 | def call(context)
9 | case context.request.path
10 | when "/oauth/authorize"
11 | return call_next(context) unless context.request.method == "GET"
12 | AuthorizationHandler.handle(context)
13 | when "/oauth/token"
14 | return call_next(context) unless context.request.method == "POST"
15 | if context.request.form_params["grant_type"] == "refresh_token"
16 | RefreshTokenHandler.handle(context)
17 | else
18 | AccessTokenHandler.handle(context)
19 | end
20 | when "/introspect"
21 | return call_next(context) unless context.request.method == "POST"
22 | IntrospectHandler.handle(context)
23 | when "/revoke"
24 | return call_next(context) unless context.request.method == "POST"
25 | RevokeHandler.handle(context)
26 | else
27 | call_next(context)
28 | end
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/src/authly/handlers/access_token_handler.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | class AccessTokenHandler
3 | def self.handle(context)
4 | # Extracting request parameters
5 | params = context.request.form_params
6 | grant_type = params.fetch("grant_type", "")
7 | client_id = params.fetch("client_id", "")
8 | client_secret = params.fetch("client_secret", "")
9 | redirect_uri = params.fetch("redirect_uri", "")
10 | username = params.fetch("username", "")
11 | password = params.fetch("password", "")
12 | refresh_token = params.fetch("refresh_token", "")
13 | code = params.fetch("code", "")
14 | code_verifier = params.fetch("code_verifier", "")
15 |
16 | access_token = Authly.access_token(
17 | grant_type: grant_type,
18 | client_id: client_id,
19 | client_secret: client_secret,
20 | username: username,
21 | password: password,
22 | redirect_uri: redirect_uri,
23 | code: code,
24 | verifier: code_verifier,
25 | refresh_token: refresh_token,
26 | )
27 |
28 | ResponseHelper.write(context, 200, "application/json", {"access_token" => access_token}.to_json)
29 | rescue e : Error
30 | ResponseHelper.write(context, e.code, "text/plain", e.message)
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/src/authly/handlers/authorization_handler.cr:
--------------------------------------------------------------------------------
1 | require "http/server/handler"
2 | require "uri"
3 |
4 | module Authly
5 | class AuthorizationHandler
6 | STATE_STORE = Authly.config.state_store
7 |
8 | def self.handle(context)
9 | params = context.request.query_params
10 | client_id = params.fetch("client_id", "")
11 | redirect_uri = params.fetch("redirect_uri", "")
12 | response_type = params.fetch("response_type", "")
13 | scope = params.fetch("scope", "")
14 | code_challenge = params.fetch("code_challenge", "")
15 | challenge_method = params.fetch("code_challenge_method", "")
16 | user_id = params.fetch("user_id", "")
17 | state = params.fetch("state", "")
18 |
19 | # Check if user has given consent
20 | unless user_has_given_consent?(context)
21 | # Store the state parameter to verify later
22 | STATE_STORE.store(state)
23 | # Render consent page where the user can approve or deny the requested access
24 | render_consent_page(context, client_id, scope, state)
25 | return
26 | end
27 |
28 | # Verify the state parameter to prevent CSRF attacks
29 | unless STATE_STORE.valid?(state)
30 | ResponseHelper.write(context, 400, "text/plain", "Invalid state parameter")
31 | return
32 | end
33 |
34 | # Generate authorization code after user consent
35 | authorization_code = Authly.code(
36 | response_type,
37 | client_id,
38 | redirect_uri,
39 | scope,
40 | code_challenge,
41 | challenge_method,
42 | user_id
43 | ).to_s
44 |
45 | # Redirect the user-agent back to the redirect_uri with the code and state
46 | redirect_location = URI.parse(redirect_uri)
47 | redirect_location.query = URI.encode_path("code=#{authorization_code}&state=#{state}")
48 | ResponseHelper.redirect(context, redirect_location.to_s)
49 | rescue e : Error
50 | ResponseHelper.write(context, e.code, "text/plain", e.message)
51 | end
52 |
53 | private def self.user_has_given_consent?(context)
54 | # Logic to determine if the user has already given consent
55 | # This can be done by checking session or context information
56 | consent = context.request.query_params["consent"]?
57 | consent == "approved"
58 | end
59 |
60 | private def self.render_consent_page(context, client_id, scope, state = SecureRandom.hex(32))
61 | # Render a simple consent page where the user can approve or deny the requested access
62 | ResponseHelper.write(context, 200, "text/html", <<-HTML)
63 |
64 |
65 | Authorization Request
66 | Client ID: #{client_id}
67 | Requested Scopes: #{scope}
68 |
76 |
77 |
78 | HTML
79 | end
80 |
81 | private def self.valid_state?(state)
82 | # Verify that the state parameter exists in the store
83 | STATE_STORE.valid?(state) == true
84 | end
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/src/authly/handlers/introspect_hanlder.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | class IntrospectHandler
3 | def self.handle(context)
4 | if context.request.method == "POST"
5 | # Extracting request parameters
6 | params = context.request.form_params
7 | token = params["token"]
8 | introspection_result = Authly.introspect(token)
9 | ResponseHelper.write(context, 200, "application/json", introspection_result.to_json)
10 | else
11 | end
12 | rescue e : Error
13 | ResponseHelper.write(context, e.code, "text/plain", e.message)
14 | rescue e : KeyError
15 | ResponseHelper.write(context, 400, "text/plain", e.message)
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/src/authly/handlers/refresh_token_handler.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | class RefreshTokenHandler
3 | def self.handle(context : HTTP::Server::Context)
4 | # Extract parameters from the request
5 | client_id = context.request.form_params["client_id"]?
6 | client_secret = context.request.form_params["client_secret"]?
7 | refresh_token = context.request.form_params["refresh_token"]?
8 |
9 | # Ensure all required parameters are present
10 | unless client_id && client_secret && refresh_token
11 | ResponseHelper.write(context, 400, "application/json", {"error" => "missing_parameters"}.to_json)
12 | return
13 | end
14 |
15 | # Instantiate the RefreshToken service
16 | token = Authly.access_token("refresh_token", client_id: client_id, client_secret: client_secret)
17 | ResponseHelper.write(
18 | context,
19 | 200,
20 | "application/json",
21 | {
22 | refresh_token: token.refresh_token,
23 | access_token: token.access_token,
24 | }.to_json
25 | )
26 | rescue e : Error
27 | ResponseHelper.write(context, e.code, "application/json", {"error" => e.message}.to_json)
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/src/authly/handlers/response_helper.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | module ResponseHelper
3 | def self.write(context, status_code, content_type = "text/plain", body = "")
4 | context.response.status_code = status_code
5 | context.response.content_type = content_type
6 | context.response.write body
7 | end
8 |
9 | def self.redirect(context, location)
10 | context.response.status_code = 302
11 | context.response.headers["Location"] = location
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/src/authly/handlers/revoke_hanlder.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | class RevokeHandler
3 | def self.handle(context)
4 | unless context.request.method == "POST"
5 | ResponseHelper.write(context, 405, "text/plain", "Method not allowed")
6 | end
7 |
8 | # Extracting request parameters
9 | params = context.request.form_params
10 | token = params["token"]
11 | Authly.revoke(token)
12 | ResponseHelper.write(context, 200, "text/plain", "Token revoked successfully")
13 | rescue e : Error
14 | ResponseHelper.write(context, e.code, "text/plain", e.message)
15 | rescue e : KeyError
16 | ResponseHelper.write(context, 400, "text/plain", e.message)
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/src/authly/owner.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | struct Owner
3 | property id : String = Random::Secure.hex(16)
4 | property username : String
5 | property password : String
6 |
7 | def initialize(@username, @password)
8 | end
9 | end
10 |
11 | class Owners
12 | include AuthorizableOwner
13 | include Enumerable(Authly::Owner)
14 |
15 | def initialize
16 | @owners = [] of Owner
17 | end
18 |
19 | def <<(owner : Owner)
20 | @owners << owner
21 | end
22 |
23 | def authorized?(username : String, password : String) : Bool
24 | any? do |owner|
25 | owner.username == username && owner.password == password
26 | end
27 | end
28 |
29 | def id_token(user_id : String) : Hash(String, String | Int64)
30 | user = find! { |owner| owner.username == user_id }
31 | {
32 |
33 | "user_id" => user.username,
34 | "sub" => user.id,
35 | "iat" => Time.utc.to_unix,
36 | "exp" => Authly.config.access_ttl.from_now.to_unix,
37 | "iss" => Authly.config.issuer,
38 | }
39 | end
40 |
41 | def each(& : Owner -> _)
42 | @owners.each { |owner| yield owner }
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/src/authly/response_type.cr:
--------------------------------------------------------------------------------
1 | require "random"
2 |
3 | module Authly
4 | class ResponseType
5 | getter type : String,
6 | client_id : String,
7 | redirect_uri : String,
8 | scope : String = "",
9 | challenge : String = "",
10 | challenge_method : String = "",
11 | user_id : String = ""
12 |
13 | def initialize(
14 | @type,
15 | @client_id,
16 | @redirect_uri,
17 | @scope,
18 | @challenge = "",
19 | @challenge_method = "",
20 | @user_id = ""
21 | )
22 | end
23 |
24 | def decode
25 | raise Error.invalid_redirect_uri if redirect_uri.empty?
26 | raise Error.unauthorized_client unless authorize_client(client_id, redirect_uri)
27 |
28 | case @type
29 | when "code" then code
30 | when "token" then token
31 | end
32 | end
33 |
34 | def code
35 | Code.new client_id, scope, redirect_uri, challenge, challenge_method, user_id
36 | end
37 |
38 | def token
39 | AccessToken.new(client_id, scope)
40 | end
41 |
42 | private def authorize_client(client_id, redirect_uri)
43 | Authly.clients.valid_redirect?(client_id, redirect_uri)
44 | end
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/src/authly/state_store.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | module StateStore
3 | abstract def store(state : String)
4 | abstract def valid?(state : String) : Bool
5 | end
6 |
7 | class InMemoryStateStore
8 | include StateStore
9 |
10 | @store : Hash(String, Bool)
11 |
12 | def initialize
13 | @store = Hash(String, Bool).new
14 | end
15 |
16 | def store(state : String)
17 | @store[state] = true
18 | end
19 |
20 | def valid?(state : String) : Bool
21 | @store.fetch(state, false) == true
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/src/authly/token_manager.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | module TokenStrategy
3 | abstract def revoke(token : String)
4 | abstract def revoked?(token : String) : Bool
5 | abstract def valid?(token : String) : Bool
6 | abstract def introspect(token : String)
7 | end
8 |
9 | class TokenStrategyFactory
10 | TOKEN_MANAGERS = {
11 | jwt: JWTToken.new,
12 | opaque: OpaqueToken.new,
13 | }
14 |
15 | def self.create
16 | TOKEN_MANAGERS.fetch(Authly.config.token_strategy) do
17 | raise Error.unsupported_token_type
18 | end
19 | end
20 | end
21 |
22 | class JWTToken
23 | include TokenStrategy
24 |
25 | def initialize
26 | @config = Authly.config
27 | end
28 |
29 | def revoke(token)
30 | decoded_token, _header = Authly.jwt_decode(token)
31 | jti = decoded_token["jti"].to_s
32 | @config.token_store.revoke(jti)
33 | end
34 |
35 | def revoked?(token : String) : Bool
36 | decoded_token, _header = Authly.jwt_decode(token)
37 | jti = decoded_token["jti"].to_s
38 | @config.token_store.revoked?(jti)
39 | end
40 |
41 | def valid?(token) : Bool
42 | decoded_token, _header = Authly.jwt_decode(token)
43 | return false if revoked?(token)
44 |
45 | exp = decoded_token["exp"].to_s.to_i64
46 | Time.utc.to_unix < exp
47 | rescue e : JWT::DecodeError
48 | Log.error { "Invalid token - #{e.message}" }
49 | false
50 | end
51 |
52 | def introspect(token : String)
53 | # Decode the JWT, verify the signature and expiration
54 | payload, _header = Authly.jwt_decode(token)
55 |
56 | # Check if the token is expired (exp claim is typically in seconds since epoch)
57 | if Time.local.to_unix > payload["exp"].to_s.to_i64
58 | return {"active" => false, "exp" => payload["exp"].as_i64}
59 | end
60 |
61 | # Return authly access token
62 | {
63 | "active" => true,
64 | "scope" => payload["scope"].as_s,
65 | "cid" => payload["cid"].as_s,
66 | "exp" => payload["exp"].as_i64,
67 | "sub" => payload["sub"].as_s,
68 | }
69 | rescue JWT::DecodeError
70 | {"active" => false}
71 | end
72 | end
73 |
74 | class OpaqueToken
75 | include TokenStrategy
76 |
77 | def initialize
78 | @config = Authly.config
79 | @token_store = @config.token_store
80 | end
81 |
82 | def revoke(token)
83 | @token_store.revoke(token)
84 | end
85 |
86 | def revoked?(token) : Bool
87 | @token_store.revoked?(token)
88 | end
89 |
90 | def valid?(token) : Bool
91 | @token_store.valid?(token)
92 | end
93 |
94 | def introspect(token : String)
95 | payload = @token_store.fetch(token)
96 | return {"active" => false} if payload.nil?
97 |
98 | {"active" => true, "token" => payload}
99 | rescue e : Error
100 | {"active" => false}
101 | end
102 | end
103 |
104 | class TokenManager
105 | def self.instance
106 | @@instance ||= new
107 | end
108 |
109 | def initialize(
110 | @config = Authly.config,
111 | @token_manager : TokenStrategy = TokenStrategyFactory.create
112 | )
113 | end
114 |
115 | def revoke(token)
116 | @token_manager.revoke(token)
117 | end
118 |
119 | def revoked?(token)
120 | @token_manager.revoked?(token)
121 | end
122 |
123 | def valid?(token)
124 | @token_manager.valid?(token)
125 | end
126 |
127 | def introspect(token : String)
128 | @token_manager.introspect(token)
129 | end
130 | end
131 | end
132 |
--------------------------------------------------------------------------------
/src/authly/token_store.cr:
--------------------------------------------------------------------------------
1 | module Authly
2 | module TokenStore
3 | abstract def store(token_id : String, payload)
4 | abstract def fetch(token_id : String)
5 | abstract def revoke(token_id : String)
6 | abstract def revoked?(token_id : String) : Bool
7 | abstract def valid?(token_id : String) : Bool
8 | end
9 |
10 | class InMemoryStore
11 | include Authly::TokenStore
12 | include Enumerable(String)
13 |
14 | # Use a set to track revoked tokens by their jti
15 | def initialize
16 | @revoked_tokens = Set(String).new
17 | @tokens = {} of String => Hash(String, String | Int64 | Bool)
18 | end
19 |
20 | # Implement the each method to make the class enumerable
21 | def each(&)
22 | @revoked_tokens.each { |jti| yield jti }
23 | end
24 |
25 | # Method to store a token by its jti
26 | def store(token_id : String, payload)
27 | @tokens[token_id] = payload
28 | end
29 |
30 | def fetch(token_id : String)
31 | @tokens.fetch(token_id) { raise Error.invalid_token }
32 | end
33 |
34 | # Method to revoke a token by its jti
35 | def revoke(token_id : String)
36 | @revoked_tokens.add(token_id)
37 | end
38 |
39 | # Method to check if a token's jti has been revoked
40 | def revoked?(token_id : String) : Bool
41 | @revoked_tokens.includes?(token_id)
42 | end
43 |
44 | def valid?(token_id : String) : Bool
45 | !revoked?(token_id)
46 | end
47 | end
48 | end
49 |
--------------------------------------------------------------------------------