shakha 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +84 -0
- data/app/assets/stylesheets/shakha.css +193 -0
- data/app/controllers/shakha/application_controller.rb +20 -0
- data/app/controllers/shakha/auth_controller.rb +189 -0
- data/app/controllers/shakha/jwks_controller.rb +10 -0
- data/app/controllers/shakha/openid_controller.rb +21 -0
- data/app/controllers/shakha/session_controller.rb +33 -0
- data/app/models/shakha/client.rb +20 -0
- data/app/models/shakha/session.rb +33 -0
- data/app/models/shakha/user.rb +16 -0
- data/app/views/shakha/auth/callback.html.erb +12 -0
- data/app/views/shakha/auth/new.html.erb +29 -0
- data/app/views/shakha/errors/show.html.erb +18 -0
- data/app/views/shakha/layouts/shakha.html.erb +12 -0
- data/generators/shakha/install_generator.rb +37 -0
- data/generators/shakha/templates/initializer.rb.erb +29 -0
- data/generators/shakha/templates/migration.rb.erb +45 -0
- data/lib/shakha/config.rb +39 -0
- data/lib/shakha/controller_helpers.rb +70 -0
- data/lib/shakha/engine.rb +93 -0
- data/lib/shakha/error_handler.rb +34 -0
- data/lib/shakha/jwt_handler.rb +127 -0
- data/lib/shakha/middleware.rb +49 -0
- data/lib/shakha/pairwise.rb +26 -0
- data/lib/shakha/pkce.rb +63 -0
- data/lib/shakha/version.rb +5 -0
- data/lib/shakha.rb +44 -0
- metadata +99 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dab553b64eb62de5d3a4225c5a4aa4fb470f8c6cf4aeea518f549dd664b7e67a
|
|
4
|
+
data.tar.gz: 0206f67ee329e747bad7da7cd3082c6be17811e19ef97ec67843d61bf50922de
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1704c398b0c6cdfa415a08f0662574eab25acb72b28ac9d8e8b83658750793111aeb9f24248ba22641ebc566500d6fbef2e63b250a895f43d6c7eb451c877d07
|
|
7
|
+
data.tar.gz: 9eab6f79cb46672c4df6dfc26507e91518616ac67ae7119bd07bfe7a177e7488f97918c09280eafcf8192953014d264bbc5beacc9c6bd5ced23fbb2e885f1e26
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shakha Authors
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Shakha
|
|
2
|
+
|
|
3
|
+
Minimal auth broker for Google OAuth with PKCE and pairwise subjects.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "shakha"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Run the installer:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
rails generate shakha:install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Set environment variables:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
export SHAKHA_APP_ORIGIN="https://yourapp.com"
|
|
23
|
+
export SHAKHA_SERVICE_SECRET="your-secret-key"
|
|
24
|
+
export GOOGLE_CLIENT_ID="your-google-client-id"
|
|
25
|
+
export GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
See `config/initializers/shakha.rb` for all options.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
### Sign In
|
|
35
|
+
|
|
36
|
+
Redirect users to sign in:
|
|
37
|
+
|
|
38
|
+
```erb
|
|
39
|
+
<%= link_to "Sign in with Google", shakha.new_auth_path %>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Current User
|
|
43
|
+
|
|
44
|
+
In controllers:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
class ApplicationController < ActionController::Base
|
|
48
|
+
include Shakha::ControllerHelpers
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
current_user # Shakha::User or nil
|
|
54
|
+
current_session # Shakha::Session or nil
|
|
55
|
+
signed_in? # boolean
|
|
56
|
+
authenticate! # redirect to login if not signed in
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Protect Routes
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
class PostsController < ApplicationController
|
|
63
|
+
before_action :authenticate!
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### JWT Verification (API Mode)
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
payload = Shakha.verify_token(id_token)
|
|
71
|
+
user_id = payload[:sub]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Architecture
|
|
75
|
+
|
|
76
|
+
- **PKCE** — S256 code challenges on every flow
|
|
77
|
+
- **Pairwise subjects** — domain-scoped user identifiers
|
|
78
|
+
- **ES256 JWTs** — signed with JWKS endpoint
|
|
79
|
+
- **Database sessions** — DHH-style, no Redis
|
|
80
|
+
- **Turbo native** — zero JS needed
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
@layer shakha.base {
|
|
2
|
+
:root {
|
|
3
|
+
--shakha-bg: oklch(98% 0.002 250);
|
|
4
|
+
--shakha-surface: oklch(100% 0 0);
|
|
5
|
+
--shakha-border: oklch(87% 0.01 250);
|
|
6
|
+
--shakha-text: oklch(20% 0.03 250);
|
|
7
|
+
--shakha-text-muted: oklch(50% 0.02 250);
|
|
8
|
+
--shakha-primary: oklch(55% 0.2 250);
|
|
9
|
+
--shakha-primary-hover: oklch(50% 0.22 250);
|
|
10
|
+
--shakha-error: oklch(60% 0.2 25);
|
|
11
|
+
--shakha-radius: 8px;
|
|
12
|
+
--shakha-shadow: 0 1px 3px oklch(0% 0 0 / 0.05), 0 1px 2px oklch(0% 0 0 / 0.1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
* {
|
|
16
|
+
margin: 0;
|
|
17
|
+
padding: 0;
|
|
18
|
+
box-sizing: border-box;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
body {
|
|
22
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
23
|
+
background: var(--shakha-bg);
|
|
24
|
+
color: var(--shakha-text);
|
|
25
|
+
line-height: 1.5;
|
|
26
|
+
-webkit-font-smoothing: antialiased;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
a {
|
|
30
|
+
color: var(--shakha-primary);
|
|
31
|
+
text-decoration: none;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
a:hover {
|
|
35
|
+
text-decoration: underline;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@layer shakha.layout {
|
|
40
|
+
.shakha-container {
|
|
41
|
+
min-height: 100vh;
|
|
42
|
+
display: flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
justify-content: center;
|
|
45
|
+
padding: 1.5rem;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.shakha-card {
|
|
49
|
+
width: 100%;
|
|
50
|
+
max-width: 400px;
|
|
51
|
+
background: var(--shakha-surface);
|
|
52
|
+
border: 1px solid var(--shakha-border);
|
|
53
|
+
border-radius: calc(var(--shakha-radius) + 4px);
|
|
54
|
+
box-shadow: var(--shakha-shadow);
|
|
55
|
+
overflow: hidden;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.shakha-card-center {
|
|
59
|
+
display: flex;
|
|
60
|
+
flex-direction: column;
|
|
61
|
+
align-items: center;
|
|
62
|
+
justify-content: center;
|
|
63
|
+
min-height: 200px;
|
|
64
|
+
padding: 2rem;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.shakha-card-error {
|
|
68
|
+
text-align: center;
|
|
69
|
+
padding: 2rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.shakha-header {
|
|
73
|
+
padding: 1.5rem 1.5rem 0;
|
|
74
|
+
text-align: center;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.shakha-header h1 {
|
|
78
|
+
font-size: 1.25rem;
|
|
79
|
+
font-weight: 600;
|
|
80
|
+
color: var(--shakha-text);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.shakha-body {
|
|
84
|
+
padding: 1.5rem;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@layer shakha.components {
|
|
89
|
+
.shakha-button {
|
|
90
|
+
display: flex;
|
|
91
|
+
align-items: center;
|
|
92
|
+
justify-content: center;
|
|
93
|
+
gap: 0.75rem;
|
|
94
|
+
width: 100%;
|
|
95
|
+
padding: 0.75rem 1rem;
|
|
96
|
+
border-radius: var(--shakha-radius);
|
|
97
|
+
font-size: 0.9375rem;
|
|
98
|
+
font-weight: 500;
|
|
99
|
+
text-decoration: none;
|
|
100
|
+
cursor: pointer;
|
|
101
|
+
transition: background 0.15s ease, border-color 0.15s ease;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.shakha-button-google {
|
|
105
|
+
background: var(--shakha-surface);
|
|
106
|
+
border: 1px solid var(--shakha-border);
|
|
107
|
+
color: var(--shakha-text);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.shakha-button-google:hover {
|
|
111
|
+
background: var(--shakha-bg);
|
|
112
|
+
text-decoration: none;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.shakha-button-primary {
|
|
116
|
+
background: var(--shakha-primary);
|
|
117
|
+
border: 1px solid var(--shakha-primary);
|
|
118
|
+
color: white;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.shakha-button-primary:hover {
|
|
122
|
+
background: var(--shakha-primary-hover);
|
|
123
|
+
text-decoration: none;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.shakha-google-icon {
|
|
127
|
+
width: 18px;
|
|
128
|
+
height: 18px;
|
|
129
|
+
flex-shrink: 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.shakha-privacy {
|
|
133
|
+
margin-top: 1rem;
|
|
134
|
+
font-size: 0.75rem;
|
|
135
|
+
color: var(--shakha-text-muted);
|
|
136
|
+
text-align: center;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.shakha-privacy a {
|
|
140
|
+
color: var(--shakha-text-muted);
|
|
141
|
+
text-decoration: underline;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
@layer shakha.states {
|
|
146
|
+
.shakha-loading {
|
|
147
|
+
display: flex;
|
|
148
|
+
flex-direction: column;
|
|
149
|
+
align-items: center;
|
|
150
|
+
gap: 1rem;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.shakha-loading p {
|
|
154
|
+
color: var(--shakha-text-muted);
|
|
155
|
+
font-size: 0.875rem;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.shakha-spinner {
|
|
159
|
+
width: 32px;
|
|
160
|
+
height: 32px;
|
|
161
|
+
border: 3px solid var(--shakha-border);
|
|
162
|
+
border-top-color: var(--shakha-primary);
|
|
163
|
+
border-radius: 50%;
|
|
164
|
+
animation: shakha-spin 0.8s linear infinite;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
@keyframes shakha-spin {
|
|
168
|
+
to {
|
|
169
|
+
transform: rotate(360deg);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.shakha-error-icon {
|
|
174
|
+
margin-bottom: 1rem;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.shakha-error-icon svg {
|
|
178
|
+
width: 48px;
|
|
179
|
+
height: 48px;
|
|
180
|
+
color: var(--shakha-error);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.shakha-card-error h1 {
|
|
184
|
+
font-size: 1.25rem;
|
|
185
|
+
font-weight: 600;
|
|
186
|
+
margin-bottom: 0.5rem;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.shakha-error-message {
|
|
190
|
+
color: var(--shakha-text-muted);
|
|
191
|
+
margin-bottom: 1.5rem;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shakha
|
|
4
|
+
class ApplicationController < ActionController::Base
|
|
5
|
+
include ErrorHandler
|
|
6
|
+
include ControllerHelpers
|
|
7
|
+
|
|
8
|
+
protect_from_forgery with: :exception
|
|
9
|
+
|
|
10
|
+
layout -> { false if request.format == :json }
|
|
11
|
+
|
|
12
|
+
rescue_from ActiveSupport::ActionController::InvalidAuthenticityToken, with: :invalid_csrf_token
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def invalid_csrf_token(exception)
|
|
17
|
+
render json: { error: "Invalid CSRF token" }, status: :unprocessable_entity
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Shakha
|
|
7
|
+
class AuthController < ApplicationController
|
|
8
|
+
include PKCEMixin
|
|
9
|
+
|
|
10
|
+
skip_before_action :verify_authenticity_token, only: [:callback, :token]
|
|
11
|
+
|
|
12
|
+
def new
|
|
13
|
+
@client = find_or_create_client
|
|
14
|
+
@return_to = params[:return_to] || "/"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def authorize
|
|
18
|
+
pkce = create_pkce_bundle
|
|
19
|
+
@client = find_or_create_client
|
|
20
|
+
|
|
21
|
+
google_auth_url = build_google_auth_url(pkce)
|
|
22
|
+
|
|
23
|
+
redirect_to google_auth_url
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def callback
|
|
27
|
+
verifier = verify_pkce!(params[:code])
|
|
28
|
+
|
|
29
|
+
exchange_code_for_tokens(params[:code], verifier)
|
|
30
|
+
rescue PKCEError, GoogleOAuthError => e
|
|
31
|
+
redirect_to shakha.error_path(message: e.message)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def token
|
|
35
|
+
code = params[:code]
|
|
36
|
+
verifier = params[:code_verifier]
|
|
37
|
+
|
|
38
|
+
raise PKCEError, "Missing code" unless code
|
|
39
|
+
raise PKCEError, "Missing code_verifier" unless verifier
|
|
40
|
+
|
|
41
|
+
id_token = exchange_code_for_id_token(code, verifier)
|
|
42
|
+
|
|
43
|
+
render json: {
|
|
44
|
+
id_token: id_token,
|
|
45
|
+
pairwise_sub: id_token_payload(id_token)[:sub],
|
|
46
|
+
expires_in: 24.hours.to_i
|
|
47
|
+
}
|
|
48
|
+
rescue PKCEError, JWTError, GoogleOAuthError => e
|
|
49
|
+
render json: { error: e.message }, status: :unauthorized
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def error
|
|
53
|
+
@message = params[:message] || "Authentication failed"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def find_or_create_client
|
|
59
|
+
origin = URI.parse(request.origin).origin
|
|
60
|
+
|
|
61
|
+
Shakha::Client.find_or_create_by!(origin: origin) do |client|
|
|
62
|
+
client.name = URI.parse(request.origin).host
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_google_auth_url(pkce)
|
|
67
|
+
client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
|
|
68
|
+
redirect_uri = "#{Shakha.config.service_base_url}/auth/shakha/callback"
|
|
69
|
+
|
|
70
|
+
scopes = ["openid", "email", "profile"].join(" ")
|
|
71
|
+
scopes += " https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile" if params[:request_pii]
|
|
72
|
+
|
|
73
|
+
params = {
|
|
74
|
+
client_id: client_id,
|
|
75
|
+
redirect_uri: redirect_uri,
|
|
76
|
+
response_type: "code",
|
|
77
|
+
scope: scopes,
|
|
78
|
+
code_challenge: pkce[:challenge],
|
|
79
|
+
code_challenge_method: "S256",
|
|
80
|
+
state: pkce[:state],
|
|
81
|
+
access_type: "offline",
|
|
82
|
+
prompt: "consent"
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
URI.parse("https://accounts.google.com/o/oauth2/v2/auth").tap do |uri|
|
|
86
|
+
uri.query = URI.encode_www_form(params)
|
|
87
|
+
end.to_s
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def exchange_code_for_tokens(code, verifier)
|
|
91
|
+
client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
|
|
92
|
+
client_secret = Shakha.config.google_client_secret || ENV["GOOGLE_CLIENT_SECRET"]
|
|
93
|
+
redirect_uri = "#{Shakha.config.service_base_url}/auth/shakha/callback"
|
|
94
|
+
|
|
95
|
+
response = http_post(
|
|
96
|
+
"https://oauth2.googleapis.com/token",
|
|
97
|
+
{
|
|
98
|
+
code: code,
|
|
99
|
+
client_id: client_id,
|
|
100
|
+
client_secret: client_secret,
|
|
101
|
+
redirect_uri: redirect_uri,
|
|
102
|
+
grant_type: "authorization_code",
|
|
103
|
+
code_verifier: verifier
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
tokens = JSON.parse(response.body)
|
|
108
|
+
id_token = tokens["id_token"]
|
|
109
|
+
access_token = tokens["access_token"]
|
|
110
|
+
|
|
111
|
+
raise GoogleOAuthError, "No id_token received" unless id_token
|
|
112
|
+
|
|
113
|
+
payload = decode_id_token(id_token)
|
|
114
|
+
google_sub = payload["sub"]
|
|
115
|
+
pairwise_sub = Shakha.derive_pairwise_sub(google_sub)
|
|
116
|
+
|
|
117
|
+
client = find_or_create_client
|
|
118
|
+
user = Shakha::User.find_or_initialize_by(pairwise_sub: pairwise_sub)
|
|
119
|
+
|
|
120
|
+
if params[:request_pii] && payload["email"]
|
|
121
|
+
user.assign_attributes(
|
|
122
|
+
email: payload["email"],
|
|
123
|
+
name: payload["name"],
|
|
124
|
+
picture: payload["picture"]
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
user.save!
|
|
128
|
+
|
|
129
|
+
session_record = Shakha::Session.create!(
|
|
130
|
+
user: user,
|
|
131
|
+
client: client,
|
|
132
|
+
jti: SecureRandom.uuid
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
cookies.encrypted[:shakha_session_token] = {
|
|
136
|
+
value: session_record.token,
|
|
137
|
+
httponly: true,
|
|
138
|
+
secure: Rails.env.production?,
|
|
139
|
+
same_site: :lax,
|
|
140
|
+
expires: Shakha.config.session_lifetime.from_now
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return_to = pkce_state&.dig(:return_to) || "/"
|
|
144
|
+
|
|
145
|
+
redirect_to return_to
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def exchange_code_for_id_token(code, verifier)
|
|
149
|
+
client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
|
|
150
|
+
client_secret = Shakha.config.google_client_secret || ENV["GOOGLE_CLIENT_SECRET"]
|
|
151
|
+
redirect_uri = "#{Shakha.config.service_base_url}/auth/shakha/callback"
|
|
152
|
+
|
|
153
|
+
response = http_post(
|
|
154
|
+
"https://oauth2.googleapis.com/token",
|
|
155
|
+
{
|
|
156
|
+
code: code,
|
|
157
|
+
client_id: client_id,
|
|
158
|
+
client_secret: client_secret,
|
|
159
|
+
redirect_uri: redirect_uri,
|
|
160
|
+
grant_type: "authorization_code",
|
|
161
|
+
code_verifier: verifier
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
tokens = JSON.parse(response.body)
|
|
166
|
+
tokens["id_token"] || raise(GoogleOAuthError, "No id_token in response")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def id_token_payload(id_token)
|
|
170
|
+
JWT.decode(id_token, nil, false)[0]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def decode_id_token(id_token)
|
|
174
|
+
JWT.decode(id_token, nil, false)[0]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def http_post(url, body)
|
|
178
|
+
uri = URI.parse(url)
|
|
179
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
180
|
+
http.use_ssl = uri.scheme == "https"
|
|
181
|
+
|
|
182
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
183
|
+
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
184
|
+
request.body = URI.encode_www_form(body)
|
|
185
|
+
|
|
186
|
+
http.request(request)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shakha
|
|
4
|
+
class OpenidController < ApplicationController
|
|
5
|
+
def configuration
|
|
6
|
+
render json: {
|
|
7
|
+
issuer: Shakha.config.issuer,
|
|
8
|
+
authorization_endpoint: "#{Shakha.config.service_base_url}/auth/shakha/authorize",
|
|
9
|
+
token_endpoint: "#{Shakha.config.service_base_url}/auth/shakha/token",
|
|
10
|
+
userinfo_endpoint: "#{Shakha.config.service_base_url}/auth/shakha/session",
|
|
11
|
+
jwks_uri: "#{Shakha.config.service_base_url}/.well-known/jwks.json",
|
|
12
|
+
response_types_supported: ["code"],
|
|
13
|
+
grant_types_supported: ["authorization_code"],
|
|
14
|
+
code_challenge_methods_supported: ["S256"],
|
|
15
|
+
subject_types_supported: ["pairwise"],
|
|
16
|
+
id_token_signing_alg_values_supported: ["ES256"],
|
|
17
|
+
scopes_supported: ["openid", "email", "profile"]
|
|
18
|
+
}, content_type: "application/json"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shakha
|
|
4
|
+
class SessionController < ApplicationController
|
|
5
|
+
skip_before_action :verify_authenticity_token, only: [:check]
|
|
6
|
+
|
|
7
|
+
def show
|
|
8
|
+
render json: {
|
|
9
|
+
user_id: current_user&.pairwise_sub,
|
|
10
|
+
email: current_user&.email,
|
|
11
|
+
name: current_user&.name,
|
|
12
|
+
expires_at: current_session&.expires_at&.iso8601
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def check
|
|
17
|
+
if signed_in?
|
|
18
|
+
render json: { status: "active" }
|
|
19
|
+
else
|
|
20
|
+
render json: {
|
|
21
|
+
status: "login_required",
|
|
22
|
+
reason: "no_session"
|
|
23
|
+
}, status: :unauthorized
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def destroy
|
|
28
|
+
current_session&.destroy
|
|
29
|
+
cookies.delete(:shakha_session_token)
|
|
30
|
+
render json: { status: "signed_out" }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shakha
|
|
4
|
+
class Client < ::ApplicationRecord
|
|
5
|
+
self.table_name = "shakha_clients"
|
|
6
|
+
|
|
7
|
+
has_many :sessions, class_name: "Shakha::Session", dependent: :restrict_with_error
|
|
8
|
+
has_many :users, class_name: "Shakha::User", dependent: :nullify
|
|
9
|
+
|
|
10
|
+
validates :origin, presence: true, uniqueness: true
|
|
11
|
+
|
|
12
|
+
def client_id
|
|
13
|
+
"origin:#{origin}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.find_by_origin!(origin)
|
|
17
|
+
find_by!(origin: URI.parse(origin).origin)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shakha
|
|
4
|
+
class Session < ::ApplicationRecord
|
|
5
|
+
self.table_name = "shakha_sessions"
|
|
6
|
+
|
|
7
|
+
belongs_to :user, class_name: "Shakha::User", optional: true
|
|
8
|
+
belongs_to :client, class_name: "Shakha::Client"
|
|
9
|
+
|
|
10
|
+
before_create :generate_token
|
|
11
|
+
before_create :generate_jti
|
|
12
|
+
|
|
13
|
+
scope :active, -> { where("created_at > ?", Shakha.config.session_lifetime.ago) }
|
|
14
|
+
|
|
15
|
+
def expired?
|
|
16
|
+
created_at < Shakha.config.session_lifetime.ago
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def expires_at
|
|
20
|
+
created_at + Shakha.config.session_lifetime
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def generate_token
|
|
26
|
+
self.token ||= SecureRandom.urlsafe_base64(32)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def generate_jti
|
|
30
|
+
self.jti ||= SecureRandom.uuid
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shakha
|
|
4
|
+
class User < ::ApplicationRecord
|
|
5
|
+
self.table_name = "shakha_users"
|
|
6
|
+
|
|
7
|
+
has_many :sessions, class_name: "Shakha::Session", dependent: :destroy
|
|
8
|
+
|
|
9
|
+
validates :pairwise_sub, presence: true, uniqueness: true
|
|
10
|
+
validates :email, uniqueness: true, allow_blank: true
|
|
11
|
+
|
|
12
|
+
def can_access?(resource)
|
|
13
|
+
true
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<% content_for :title, "Signing In..." %>
|
|
2
|
+
|
|
3
|
+
<div class="shakha-container">
|
|
4
|
+
<div class="shakha-card shakha-card-center">
|
|
5
|
+
<div class="shakha-loading">
|
|
6
|
+
<div class="shakha-spinner"></div>
|
|
7
|
+
<p>Signing you in...</p>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<meta http-equiv="refresh" content="2;url=<%= @return_to || "/" %>">
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<% content_for :title, "Sign In" %>
|
|
2
|
+
|
|
3
|
+
<div class="shakha-container">
|
|
4
|
+
<div class="shakha-card">
|
|
5
|
+
<div class="shakha-header">
|
|
6
|
+
<h1>Sign in to <%= @client&.name || "your app" %></h1>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="shakha-body">
|
|
10
|
+
<%= link_to shakha.authorize_path(request_pii: 1),
|
|
11
|
+
class: "shakha-button shakha-button-google",
|
|
12
|
+
data: { turbo: false } do %>
|
|
13
|
+
<svg class="shakha-google-icon" viewBox="0 0 18 18" aria-hidden="true">
|
|
14
|
+
<path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/>
|
|
15
|
+
<path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"/>
|
|
16
|
+
<path fill="#FBBC05" d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z"/>
|
|
17
|
+
<path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"/>
|
|
18
|
+
</svg>
|
|
19
|
+
Continue with Google
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
22
|
+
<p class="shakha-privacy">
|
|
23
|
+
By signing in, you agree to our
|
|
24
|
+
<%= link_to "Terms of Service", "#" %> and
|
|
25
|
+
<%= link_to "Privacy Policy", "#" %>.
|
|
26
|
+
</p>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|