├── .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 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/747eef2e02594d40b63c9f05c6b94cd9)](https://app.codacy.com/manual/eliasjpr/authly?utm_source=github.com&utm_medium=referral&utm_content=eliasjpr/authly&utm_campaign=Badge_Grade_Settings) [![Crystal CI](https://github.com/azutoolkit/authly/actions/workflows/crystal.yml/badge.svg?branch=master)](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 |
69 | 70 | 71 | 72 | 73 | 74 | 75 |
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 | --------------------------------------------------------------------------------