shakha 0.1.6 → 0.2.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 +4 -4
- data/README.md +2 -0
- data/app/assets/stylesheets/shakha.css +498 -111
- data/app/controllers/shakha/auth_controller.rb +35 -7
- data/app/controllers/shakha/session_controller.rb +7 -0
- data/app/views/shakha/auth/error.html.erb +24 -12
- data/app/views/shakha/auth/new.html.erb +29 -12
- data/app/views/shakha/auth/sessions.html.erb +66 -0
- data/app/views/shakha/layouts/shakha.html.erb +6 -3
- data/lib/shakha/config.rb +2 -1
- data/lib/shakha/engine.rb +1 -0
- data/lib/shakha/jwt_handler.rb +1 -1
- data/lib/shakha/pkce.rb +12 -18
- data/lib/shakha/rate_limiter.rb +2 -8
- data/lib/shakha/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 366829b83286435b8a3ff8a9b74cdfe319e072768efabbbfa0777d3ce7a9bb6b
|
|
4
|
+
data.tar.gz: 5aafc4d8d1db8bf6287c40ffea5dbb614903e288ddfeb35ae4d1c1e0b0ecff34
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6a7982ee421c97e9f66eb5eceb29dddd2159806d37d37bb759e84c80686390f395703adfd8b881c7a0d5eafa3c5e2bc969844ad6375ce4a04572b5e4758b5f75
|
|
7
|
+
data.tar.gz: f41c800d89c409fbcea81ab122d7f9e59028777b15643ec006ff0b6e0cddb7adcc621c463cdc538b088e171f9f4eb460cf5197b48b361700a041373c5899b2f7
|
data/README.md
CHANGED
|
@@ -42,6 +42,8 @@ class CreateShakhaTables < ActiveRecord::Migration[7.1]
|
|
|
42
42
|
t.references :client, null: false, foreign_key: { to_table: :shakha_clients }
|
|
43
43
|
t.string :token, null: false
|
|
44
44
|
t.string :jti, null: false
|
|
45
|
+
t.string :ip_address
|
|
46
|
+
t.string :user_agent
|
|
45
47
|
t.timestamps
|
|
46
48
|
t.index :token, unique: true
|
|
47
49
|
t.index :jti, unique: true
|
|
@@ -1,193 +1,580 @@
|
|
|
1
|
-
|
|
1
|
+
/* Shakha Design System — Zero JS, CSS-only */
|
|
2
|
+
|
|
3
|
+
@layer shakha.tokens {
|
|
2
4
|
:root {
|
|
3
|
-
|
|
4
|
-
--
|
|
5
|
-
--
|
|
6
|
-
--
|
|
7
|
-
--
|
|
8
|
-
--
|
|
9
|
-
--
|
|
10
|
-
--
|
|
11
|
-
--
|
|
12
|
-
--
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
5
|
+
/* Colors — OKLCH for perceptual uniformity */
|
|
6
|
+
--sh-bg: oklch(98% 0.002 250);
|
|
7
|
+
--sh-surface: oklch(100% 0 0);
|
|
8
|
+
--sh-surface-elevated: oklch(100% 0 0);
|
|
9
|
+
--sh-border: oklch(87% 0.01 250);
|
|
10
|
+
--sh-text: oklch(20% 0.03 250);
|
|
11
|
+
--sh-text-secondary: oklch(45% 0.02 250);
|
|
12
|
+
--sh-text-tertiary: oklch(60% 0.01 250);
|
|
13
|
+
--sh-primary: oklch(55% 0.18 250);
|
|
14
|
+
--sh-primary-hover: oklch(50% 0.2 250);
|
|
15
|
+
--sh-primary-subtle: oklch(95% 0.03 250);
|
|
16
|
+
--sh-error: oklch(55% 0.18 25);
|
|
17
|
+
--sh-error-subtle: oklch(95% 0.03 25);
|
|
18
|
+
--sh-success: oklch(55% 0.15 145);
|
|
19
|
+
--sh-success-subtle: oklch(95% 0.03 145);
|
|
20
|
+
|
|
21
|
+
/* Spacing — modular scale */
|
|
22
|
+
--sh-space-1: 0.25rem;
|
|
23
|
+
--sh-space-2: 0.5rem;
|
|
24
|
+
--sh-space-3: 0.75rem;
|
|
25
|
+
--sh-space-4: 1rem;
|
|
26
|
+
--sh-space-5: 1.25rem;
|
|
27
|
+
--sh-space-6: 1.5rem;
|
|
28
|
+
--sh-space-8: 2rem;
|
|
29
|
+
--sh-space-10: 2.5rem;
|
|
30
|
+
--sh-space-12: 3rem;
|
|
31
|
+
--sh-space-16: 4rem;
|
|
32
|
+
|
|
33
|
+
/* Typography */
|
|
34
|
+
--sh-font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
|
35
|
+
--sh-font-mono: "SF Mono", "Fira Code", "Cascadia Code", monospace;
|
|
36
|
+
--sh-text-xs: 0.75rem;
|
|
37
|
+
--sh-text-sm: 0.875rem;
|
|
38
|
+
--sh-text-base: 1rem;
|
|
39
|
+
--sh-text-lg: 1.125rem;
|
|
40
|
+
--sh-text-xl: 1.25rem;
|
|
41
|
+
--sh-text-2xl: 1.5rem;
|
|
42
|
+
--sh-text-3xl: 1.875rem;
|
|
43
|
+
|
|
44
|
+
/* Effects */
|
|
45
|
+
--sh-radius-sm: 6px;
|
|
46
|
+
--sh-radius-md: 10px;
|
|
47
|
+
--sh-radius-lg: 14px;
|
|
48
|
+
--sh-radius-xl: 20px;
|
|
49
|
+
--sh-shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.04);
|
|
50
|
+
--sh-shadow-md: 0 4px 12px oklch(0% 0 0 / 0.06), 0 1px 3px oklch(0% 0 0 / 0.04);
|
|
51
|
+
--sh-shadow-lg: 0 12px 40px oklch(0% 0 0 / 0.08), 0 4px 12px oklch(0% 0 0 / 0.04);
|
|
52
|
+
--sh-shadow-glow: 0 0 40px oklch(55% 0.18 250 / 0.15);
|
|
53
|
+
|
|
54
|
+
/* Transitions */
|
|
55
|
+
--sh-transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
56
|
+
--sh-transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
57
|
+
--sh-transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@media (prefers-color-scheme: dark) {
|
|
61
|
+
:root {
|
|
62
|
+
--sh-bg: oklch(18% 0.02 250);
|
|
63
|
+
--sh-surface: oklch(22% 0.025 250);
|
|
64
|
+
--sh-surface-elevated: oklch(26% 0.03 250);
|
|
65
|
+
--sh-border: oklch(35% 0.04 250);
|
|
66
|
+
--sh-text: oklch(95% 0.01 250);
|
|
67
|
+
--sh-text-secondary: oklch(75% 0.015 250);
|
|
68
|
+
--sh-text-tertiary: oklch(55% 0.02 250);
|
|
69
|
+
--sh-primary: oklch(65% 0.18 250);
|
|
70
|
+
--sh-primary-hover: oklch(70% 0.2 250);
|
|
71
|
+
--sh-primary-subtle: oklch(30% 0.06 250);
|
|
72
|
+
--sh-error: oklch(65% 0.18 25);
|
|
73
|
+
--sh-error-subtle: oklch(30% 0.05 25);
|
|
74
|
+
--sh-success: oklch(65% 0.15 145);
|
|
75
|
+
--sh-success-subtle: oklch(30% 0.04 145);
|
|
76
|
+
--sh-shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.2);
|
|
77
|
+
--sh-shadow-md: 0 4px 12px oklch(0% 0 0 / 0.3), 0 1px 3px oklch(0% 0 0 / 0.2);
|
|
78
|
+
--sh-shadow-lg: 0 12px 40px oklch(0% 0 0 / 0.4), 0 4px 12px oklch(0% 0 0 / 0.2);
|
|
79
|
+
--sh-shadow-glow: 0 0 60px oklch(65% 0.18 250 / 0.2);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@layer shakha.reset {
|
|
85
|
+
*, *::before, *::after {
|
|
16
86
|
margin: 0;
|
|
17
87
|
padding: 0;
|
|
18
88
|
box-sizing: border-box;
|
|
19
89
|
}
|
|
20
90
|
|
|
21
|
-
|
|
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;
|
|
91
|
+
html {
|
|
26
92
|
-webkit-font-smoothing: antialiased;
|
|
93
|
+
-moz-osx-font-smoothing: grayscale;
|
|
94
|
+
text-rendering: optimizeLegibility;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
body {
|
|
98
|
+
font-family: var(--sh-font-sans);
|
|
99
|
+
font-size: var(--sh-text-base);
|
|
100
|
+
line-height: 1.6;
|
|
101
|
+
background: var(--sh-bg);
|
|
102
|
+
color: var(--sh-text);
|
|
103
|
+
min-height: 100vh;
|
|
27
104
|
}
|
|
28
105
|
|
|
29
106
|
a {
|
|
30
|
-
color: var(--
|
|
107
|
+
color: var(--sh-primary);
|
|
31
108
|
text-decoration: none;
|
|
109
|
+
transition: color var(--sh-transition-fast);
|
|
32
110
|
}
|
|
33
111
|
|
|
34
112
|
a:hover {
|
|
35
|
-
|
|
113
|
+
color: var(--sh-primary-hover);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
a:focus-visible {
|
|
117
|
+
outline: 2px solid var(--sh-primary);
|
|
118
|
+
outline-offset: 2px;
|
|
119
|
+
border-radius: var(--sh-radius-sm);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
img, svg {
|
|
123
|
+
display: block;
|
|
124
|
+
max-width: 100%;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
button {
|
|
128
|
+
font-family: inherit;
|
|
129
|
+
cursor: pointer;
|
|
130
|
+
border: none;
|
|
131
|
+
background: none;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
button:focus-visible {
|
|
135
|
+
outline: 2px solid var(--sh-primary);
|
|
136
|
+
outline-offset: 2px;
|
|
36
137
|
}
|
|
37
138
|
}
|
|
38
139
|
|
|
39
140
|
@layer shakha.layout {
|
|
40
|
-
.
|
|
141
|
+
.sh-layout {
|
|
41
142
|
min-height: 100vh;
|
|
42
|
-
display:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
143
|
+
display: grid;
|
|
144
|
+
place-items: center;
|
|
145
|
+
padding: var(--sh-space-6);
|
|
146
|
+
position: relative;
|
|
147
|
+
overflow: hidden;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.sh-layout::before {
|
|
151
|
+
content: "";
|
|
152
|
+
position: absolute;
|
|
153
|
+
inset: 0;
|
|
154
|
+
background:
|
|
155
|
+
radial-gradient(ellipse 80% 50% at 20% 40%, oklch(55% 0.15 250 / 0.08), transparent 50%),
|
|
156
|
+
radial-gradient(ellipse 60% 40% at 80% 60%, oklch(65% 0.12 280 / 0.06), transparent 50%);
|
|
157
|
+
pointer-events: none;
|
|
158
|
+
z-index: 0;
|
|
46
159
|
}
|
|
47
160
|
|
|
48
|
-
.
|
|
161
|
+
.sh-card {
|
|
162
|
+
position: relative;
|
|
163
|
+
z-index: 1;
|
|
49
164
|
width: 100%;
|
|
50
|
-
max-width:
|
|
51
|
-
background: var(--
|
|
52
|
-
border: 1px solid var(--
|
|
53
|
-
border-radius:
|
|
54
|
-
box-shadow: var(--
|
|
165
|
+
max-width: 420px;
|
|
166
|
+
background: var(--sh-surface);
|
|
167
|
+
border: 1px solid var(--sh-border);
|
|
168
|
+
border-radius: var(--sh-radius-xl);
|
|
169
|
+
box-shadow: var(--sh-shadow-lg);
|
|
55
170
|
overflow: hidden;
|
|
171
|
+
transition: transform var(--sh-transition-base), box-shadow var(--sh-transition-base);
|
|
56
172
|
}
|
|
57
173
|
|
|
58
|
-
.
|
|
59
|
-
|
|
60
|
-
flex-direction: column;
|
|
61
|
-
align-items: center;
|
|
62
|
-
justify-content: center;
|
|
63
|
-
min-height: 200px;
|
|
64
|
-
padding: 2rem;
|
|
174
|
+
.sh-card:hover {
|
|
175
|
+
box-shadow: var(--sh-shadow-lg), var(--sh-shadow-glow);
|
|
65
176
|
}
|
|
66
177
|
|
|
67
|
-
.
|
|
178
|
+
.sh-card__header {
|
|
179
|
+
padding: var(--sh-space-8) var(--sh-space-8) var(--sh-space-4);
|
|
68
180
|
text-align: center;
|
|
69
|
-
padding: 2rem;
|
|
70
181
|
}
|
|
71
182
|
|
|
72
|
-
.
|
|
73
|
-
padding:
|
|
74
|
-
text-align: center;
|
|
183
|
+
.sh-card__body {
|
|
184
|
+
padding: var(--sh-space-4) var(--sh-space-8) var(--sh-space-8);
|
|
75
185
|
}
|
|
76
186
|
|
|
77
|
-
.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
187
|
+
.sh-card__footer {
|
|
188
|
+
padding: var(--sh-space-4) var(--sh-space-8);
|
|
189
|
+
background: var(--sh-primary-subtle);
|
|
190
|
+
border-top: 1px solid var(--sh-border);
|
|
191
|
+
text-align: center;
|
|
81
192
|
}
|
|
82
193
|
|
|
83
|
-
|
|
84
|
-
|
|
194
|
+
@media (prefers-color-scheme: dark) {
|
|
195
|
+
.sh-card__footer {
|
|
196
|
+
background: oklch(25% 0.04 250);
|
|
197
|
+
}
|
|
85
198
|
}
|
|
86
199
|
}
|
|
87
200
|
|
|
88
201
|
@layer shakha.components {
|
|
89
|
-
|
|
202
|
+
/* Logo / Brand */
|
|
203
|
+
.sh-brand {
|
|
90
204
|
display: flex;
|
|
91
205
|
align-items: center;
|
|
92
206
|
justify-content: center;
|
|
93
|
-
gap:
|
|
207
|
+
gap: var(--sh-space-3);
|
|
208
|
+
margin-bottom: var(--sh-space-6);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.sh-brand__icon {
|
|
212
|
+
width: 40px;
|
|
213
|
+
height: 40px;
|
|
214
|
+
background: linear-gradient(135deg, var(--sh-primary), oklch(65% 0.15 280));
|
|
215
|
+
border-radius: var(--sh-radius-md);
|
|
216
|
+
display: grid;
|
|
217
|
+
place-items: center;
|
|
218
|
+
color: white;
|
|
219
|
+
font-weight: 700;
|
|
220
|
+
font-size: var(--sh-text-lg);
|
|
221
|
+
box-shadow: 0 4px 12px oklch(55% 0.18 250 / 0.3);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.sh-brand__name {
|
|
225
|
+
font-size: var(--sh-text-xl);
|
|
226
|
+
font-weight: 700;
|
|
227
|
+
color: var(--sh-text);
|
|
228
|
+
letter-spacing: -0.02em;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.sh-brand__subtitle {
|
|
232
|
+
font-size: var(--sh-text-sm);
|
|
233
|
+
color: var(--sh-text-tertiary);
|
|
234
|
+
margin-top: var(--sh-space-1);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* Headings */
|
|
238
|
+
.sh-heading {
|
|
239
|
+
font-size: var(--sh-text-2xl);
|
|
240
|
+
font-weight: 700;
|
|
241
|
+
color: var(--sh-text);
|
|
242
|
+
letter-spacing: -0.02em;
|
|
243
|
+
line-height: 1.2;
|
|
244
|
+
margin-bottom: var(--sh-space-2);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.sh-heading--center {
|
|
248
|
+
text-align: center;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.sh-subheading {
|
|
252
|
+
font-size: var(--sh-text-sm);
|
|
253
|
+
color: var(--sh-text-secondary);
|
|
254
|
+
text-align: center;
|
|
255
|
+
margin-bottom: var(--sh-space-6);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* Buttons */
|
|
259
|
+
.sh-btn {
|
|
260
|
+
display: inline-flex;
|
|
261
|
+
align-items: center;
|
|
262
|
+
justify-content: center;
|
|
263
|
+
gap: var(--sh-space-3);
|
|
94
264
|
width: 100%;
|
|
95
|
-
padding:
|
|
96
|
-
border-radius: var(--
|
|
97
|
-
font-size:
|
|
98
|
-
font-weight:
|
|
265
|
+
padding: var(--sh-space-4) var(--sh-space-6);
|
|
266
|
+
border-radius: var(--sh-radius-md);
|
|
267
|
+
font-size: var(--sh-text-base);
|
|
268
|
+
font-weight: 600;
|
|
99
269
|
text-decoration: none;
|
|
100
|
-
|
|
101
|
-
|
|
270
|
+
transition: all var(--sh-transition-fast);
|
|
271
|
+
position: relative;
|
|
272
|
+
overflow: hidden;
|
|
102
273
|
}
|
|
103
274
|
|
|
104
|
-
.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
275
|
+
.sh-btn::after {
|
|
276
|
+
content: "";
|
|
277
|
+
position: absolute;
|
|
278
|
+
inset: 0;
|
|
279
|
+
background: linear-gradient(90deg, transparent, oklch(100% 0 0 / 0.1), transparent);
|
|
280
|
+
transform: translateX(-100%);
|
|
281
|
+
transition: transform var(--sh-transition-slow);
|
|
108
282
|
}
|
|
109
283
|
|
|
110
|
-
.
|
|
111
|
-
|
|
112
|
-
text-decoration: none;
|
|
284
|
+
.sh-btn:hover::after {
|
|
285
|
+
transform: translateX(100%);
|
|
113
286
|
}
|
|
114
287
|
|
|
115
|
-
.
|
|
116
|
-
background: var(--
|
|
117
|
-
border: 1px solid var(--shakha-primary);
|
|
288
|
+
.sh-btn--primary {
|
|
289
|
+
background: var(--sh-primary);
|
|
118
290
|
color: white;
|
|
291
|
+
border: 1px solid transparent;
|
|
292
|
+
box-shadow: 0 1px 3px oklch(0% 0 0 / 0.1);
|
|
119
293
|
}
|
|
120
294
|
|
|
121
|
-
.
|
|
122
|
-
background: var(--
|
|
295
|
+
.sh-btn--primary:hover {
|
|
296
|
+
background: var(--sh-primary-hover);
|
|
297
|
+
transform: translateY(-1px);
|
|
298
|
+
box-shadow: 0 4px 12px oklch(55% 0.18 250 / 0.25);
|
|
123
299
|
text-decoration: none;
|
|
124
300
|
}
|
|
125
301
|
|
|
126
|
-
.
|
|
127
|
-
|
|
128
|
-
|
|
302
|
+
.sh-btn--google {
|
|
303
|
+
background: var(--sh-surface);
|
|
304
|
+
color: var(--sh-text);
|
|
305
|
+
border: 1px solid var(--sh-border);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.sh-btn--google:hover {
|
|
309
|
+
background: var(--sh-surface-elevated);
|
|
310
|
+
border-color: var(--sh-primary);
|
|
311
|
+
transform: translateY(-1px);
|
|
312
|
+
box-shadow: var(--sh-shadow-md);
|
|
313
|
+
text-decoration: none;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.sh-btn--danger {
|
|
317
|
+
background: var(--sh-error);
|
|
318
|
+
color: white;
|
|
319
|
+
border: 1px solid transparent;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.sh-btn--danger:hover {
|
|
323
|
+
background: oklch(50% 0.2 25);
|
|
324
|
+
transform: translateY(-1px);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.sh-btn--ghost {
|
|
328
|
+
background: transparent;
|
|
329
|
+
color: var(--sh-text-secondary);
|
|
330
|
+
border: 1px solid var(--sh-border);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.sh-btn--ghost:hover {
|
|
334
|
+
background: var(--sh-primary-subtle);
|
|
335
|
+
color: var(--sh-primary);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.sh-btn__icon {
|
|
339
|
+
width: 20px;
|
|
340
|
+
height: 20px;
|
|
129
341
|
flex-shrink: 0;
|
|
130
342
|
}
|
|
131
343
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
font-size: 0.75rem;
|
|
135
|
-
color: var(--shakha-text-muted);
|
|
344
|
+
/* Error states */
|
|
345
|
+
.sh-error {
|
|
136
346
|
text-align: center;
|
|
347
|
+
padding: var(--sh-space-8);
|
|
137
348
|
}
|
|
138
349
|
|
|
139
|
-
.
|
|
140
|
-
|
|
141
|
-
|
|
350
|
+
.sh-error__icon {
|
|
351
|
+
width: 64px;
|
|
352
|
+
height: 64px;
|
|
353
|
+
margin: 0 auto var(--sh-space-6);
|
|
354
|
+
background: var(--sh-error-subtle);
|
|
355
|
+
border-radius: var(--sh-radius-lg);
|
|
356
|
+
display: grid;
|
|
357
|
+
place-items: center;
|
|
358
|
+
color: var(--sh-error);
|
|
359
|
+
animation: sh-pulse 2s ease-in-out infinite;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
@keyframes sh-pulse {
|
|
363
|
+
0%, 100% { transform: scale(1); opacity: 1; }
|
|
364
|
+
50% { transform: scale(1.05); opacity: 0.8; }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.sh-error__title {
|
|
368
|
+
font-size: var(--sh-text-xl);
|
|
369
|
+
font-weight: 700;
|
|
370
|
+
color: var(--sh-text);
|
|
371
|
+
margin-bottom: var(--sh-space-2);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.sh-error__message {
|
|
375
|
+
font-size: var(--sh-text-base);
|
|
376
|
+
color: var(--sh-text-secondary);
|
|
377
|
+
margin-bottom: var(--sh-space-6);
|
|
378
|
+
line-height: 1.6;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/* Success states */
|
|
382
|
+
.sh-success {
|
|
383
|
+
text-align: center;
|
|
384
|
+
padding: var(--sh-space-8);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.sh-success__icon {
|
|
388
|
+
width: 64px;
|
|
389
|
+
height: 64px;
|
|
390
|
+
margin: 0 auto var(--sh-space-6);
|
|
391
|
+
background: var(--sh-success-subtle);
|
|
392
|
+
border-radius: var(--sh-radius-lg);
|
|
393
|
+
display: grid;
|
|
394
|
+
place-items: center;
|
|
395
|
+
color: var(--sh-success);
|
|
142
396
|
}
|
|
143
|
-
}
|
|
144
397
|
|
|
145
|
-
|
|
146
|
-
.
|
|
398
|
+
/* Session list */
|
|
399
|
+
.sh-sessions {
|
|
400
|
+
padding: var(--sh-space-6);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.sh-sessions__header {
|
|
147
404
|
display: flex;
|
|
148
|
-
flex-direction: column;
|
|
149
405
|
align-items: center;
|
|
150
|
-
|
|
406
|
+
justify-content: space-between;
|
|
407
|
+
margin-bottom: var(--sh-space-6);
|
|
408
|
+
padding-bottom: var(--sh-space-4);
|
|
409
|
+
border-bottom: 1px solid var(--sh-border);
|
|
151
410
|
}
|
|
152
411
|
|
|
153
|
-
.
|
|
154
|
-
|
|
155
|
-
font-
|
|
412
|
+
.sh-sessions__title {
|
|
413
|
+
font-size: var(--sh-text-lg);
|
|
414
|
+
font-weight: 700;
|
|
415
|
+
color: var(--sh-text);
|
|
156
416
|
}
|
|
157
417
|
|
|
158
|
-
.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
border-radius:
|
|
164
|
-
animation: shakha-spin 0.8s linear infinite;
|
|
418
|
+
.sh-sessions__count {
|
|
419
|
+
font-size: var(--sh-text-sm);
|
|
420
|
+
color: var(--sh-text-tertiary);
|
|
421
|
+
background: var(--sh-primary-subtle);
|
|
422
|
+
padding: var(--sh-space-1) var(--sh-space-3);
|
|
423
|
+
border-radius: var(--sh-radius-sm);
|
|
165
424
|
}
|
|
166
425
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
426
|
+
.sh-session {
|
|
427
|
+
display: flex;
|
|
428
|
+
align-items: center;
|
|
429
|
+
justify-content: space-between;
|
|
430
|
+
padding: var(--sh-space-4) var(--sh-space-4);
|
|
431
|
+
border-radius: var(--sh-radius-md);
|
|
432
|
+
border: 1px solid var(--sh-border);
|
|
433
|
+
margin-bottom: var(--sh-space-3);
|
|
434
|
+
transition: all var(--sh-transition-fast);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
.sh-session:hover {
|
|
438
|
+
background: var(--sh-primary-subtle);
|
|
439
|
+
border-color: var(--sh-primary);
|
|
440
|
+
transform: translateX(2px);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.sh-session--current {
|
|
444
|
+
border-color: var(--sh-primary);
|
|
445
|
+
background: var(--sh-primary-subtle);
|
|
446
|
+
box-shadow: 0 0 0 1px var(--sh-primary);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.sh-session__info {
|
|
450
|
+
flex: 1;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
.sh-session__device {
|
|
454
|
+
font-weight: 600;
|
|
455
|
+
color: var(--sh-text);
|
|
456
|
+
font-size: var(--sh-text-sm);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
.sh-session__meta {
|
|
460
|
+
font-size: var(--sh-text-xs);
|
|
461
|
+
color: var(--sh-text-tertiary);
|
|
462
|
+
margin-top: var(--sh-space-1);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.sh-session__badge {
|
|
466
|
+
font-size: var(--sh-text-xs);
|
|
467
|
+
font-weight: 600;
|
|
468
|
+
padding: var(--sh-space-1) var(--sh-space-2);
|
|
469
|
+
border-radius: var(--sh-radius-sm);
|
|
470
|
+
background: var(--sh-primary);
|
|
471
|
+
color: white;
|
|
472
|
+
margin-right: var(--sh-space-3);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
.sh-session__actions {
|
|
476
|
+
display: flex;
|
|
477
|
+
gap: var(--sh-space-2);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
.sh-session__btn {
|
|
481
|
+
padding: var(--sh-space-2) var(--sh-space-3);
|
|
482
|
+
border-radius: var(--sh-radius-sm);
|
|
483
|
+
font-size: var(--sh-text-xs);
|
|
484
|
+
font-weight: 600;
|
|
485
|
+
border: 1px solid var(--sh-border);
|
|
486
|
+
background: var(--sh-surface);
|
|
487
|
+
color: var(--sh-text-secondary);
|
|
488
|
+
transition: all var(--sh-transition-fast);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.sh-session__btn:hover {
|
|
492
|
+
background: var(--sh-error);
|
|
493
|
+
color: white;
|
|
494
|
+
border-color: var(--sh-error);
|
|
171
495
|
}
|
|
172
496
|
|
|
173
|
-
|
|
174
|
-
|
|
497
|
+
/* Empty state */
|
|
498
|
+
.sh-empty {
|
|
499
|
+
text-align: center;
|
|
500
|
+
padding: var(--sh-space-12);
|
|
501
|
+
color: var(--sh-text-tertiary);
|
|
175
502
|
}
|
|
176
503
|
|
|
177
|
-
.
|
|
504
|
+
.sh-empty__icon {
|
|
178
505
|
width: 48px;
|
|
179
506
|
height: 48px;
|
|
180
|
-
|
|
507
|
+
margin: 0 auto var(--sh-space-4);
|
|
508
|
+
opacity: 0.5;
|
|
181
509
|
}
|
|
182
510
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
511
|
+
/* Links */
|
|
512
|
+
.sh-link {
|
|
513
|
+
color: var(--sh-primary);
|
|
514
|
+
font-size: var(--sh-text-sm);
|
|
515
|
+
font-weight: 500;
|
|
516
|
+
transition: all var(--sh-transition-fast);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
.sh-link:hover {
|
|
520
|
+
text-decoration: underline;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
.sh-link--subtle {
|
|
524
|
+
color: var(--sh-text-tertiary);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
.sh-link--subtle:hover {
|
|
528
|
+
color: var(--sh-text-secondary);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/* Divider */
|
|
532
|
+
.sh-divider {
|
|
533
|
+
display: flex;
|
|
534
|
+
align-items: center;
|
|
535
|
+
gap: var(--sh-space-4);
|
|
536
|
+
margin: var(--sh-space-6) 0;
|
|
537
|
+
color: var(--sh-text-tertiary);
|
|
538
|
+
font-size: var(--sh-text-xs);
|
|
539
|
+
text-transform: uppercase;
|
|
540
|
+
letter-spacing: 0.05em;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
.sh-divider::before,
|
|
544
|
+
.sh-divider::after {
|
|
545
|
+
content: "";
|
|
546
|
+
flex: 1;
|
|
547
|
+
height: 1px;
|
|
548
|
+
background: var(--sh-border);
|
|
187
549
|
}
|
|
188
550
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
551
|
+
/* Animations */
|
|
552
|
+
@keyframes sh-fade-in {
|
|
553
|
+
from { opacity: 0; transform: translateY(10px); }
|
|
554
|
+
to { opacity: 1; transform: translateY(0); }
|
|
192
555
|
}
|
|
193
|
-
|
|
556
|
+
|
|
557
|
+
@keyframes sh-slide-up {
|
|
558
|
+
from { opacity: 0; transform: translateY(20px); }
|
|
559
|
+
to { opacity: 1; transform: translateY(0); }
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
.sh-animate-fade {
|
|
563
|
+
animation: sh-fade-in var(--sh-transition-slow) both;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
.sh-animate-slide {
|
|
567
|
+
animation: sh-slide-up var(--sh-transition-slow) both;
|
|
568
|
+
animation-delay: 100ms;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
@layer shakha.utilities {
|
|
573
|
+
.sh-text-center { text-align: center; }
|
|
574
|
+
.sh-mt-2 { margin-top: var(--sh-space-2); }
|
|
575
|
+
.sh-mt-4 { margin-top: var(--sh-space-4); }
|
|
576
|
+
.sh-mt-6 { margin-top: var(--sh-space-6); }
|
|
577
|
+
.sh-mb-2 { margin-bottom: var(--sh-space-2); }
|
|
578
|
+
.sh-mb-4 { margin-bottom: var(--sh-space-4); }
|
|
579
|
+
.sh-mb-6 { margin-bottom: var(--sh-space-6); }
|
|
580
|
+
}
|
|
@@ -26,8 +26,8 @@ module Shakha
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def callback
|
|
29
|
-
pkce_result = verify_pkce!(params[:
|
|
30
|
-
exchange_code_for_tokens(params[:code], pkce_result[:verifier], pkce_result[:return_to])
|
|
29
|
+
pkce_result = verify_pkce!(params[:state])
|
|
30
|
+
exchange_code_for_tokens(params[:code], pkce_result[:verifier], pkce_result[:return_to], pkce_result[:nonce])
|
|
31
31
|
rescue PKCEError, GoogleOAuthError => e
|
|
32
32
|
ActiveSupport::Notifications.instrument("shakha.sign_in_failed", {
|
|
33
33
|
reason: e.class.name,
|
|
@@ -65,14 +65,25 @@ module Shakha
|
|
|
65
65
|
return "/" if raw.blank?
|
|
66
66
|
|
|
67
67
|
uri = URI.parse(raw)
|
|
68
|
-
|
|
68
|
+
app_host = URI.parse(Shakha.config.app_origin).host
|
|
69
|
+
|
|
70
|
+
# Must have a path
|
|
69
71
|
return "/" unless uri.path.present? && uri.path.start_with?("/")
|
|
70
72
|
|
|
71
|
-
|
|
73
|
+
# If external host, must be in allowed origins
|
|
74
|
+
if uri.host.present? && uri.host != app_host && !allowed_origin?(uri.origin)
|
|
75
|
+
return "/"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
raw
|
|
72
79
|
rescue URI::InvalidURIError
|
|
73
80
|
"/"
|
|
74
81
|
end
|
|
75
82
|
|
|
83
|
+
def allowed_origin?(origin)
|
|
84
|
+
Shakha.config.allowed_redirect_origins&.include?(origin) || false
|
|
85
|
+
end
|
|
86
|
+
|
|
76
87
|
def app_origin_host
|
|
77
88
|
URI.parse(Shakha.config.app_origin).host
|
|
78
89
|
end
|
|
@@ -125,6 +136,7 @@ module Shakha
|
|
|
125
136
|
code_challenge: pkce[:challenge],
|
|
126
137
|
code_challenge_method: "S256",
|
|
127
138
|
state: pkce[:state],
|
|
139
|
+
nonce: pkce[:nonce],
|
|
128
140
|
access_type: "offline",
|
|
129
141
|
prompt: "consent"
|
|
130
142
|
}
|
|
@@ -134,7 +146,7 @@ module Shakha
|
|
|
134
146
|
end.to_s
|
|
135
147
|
end
|
|
136
148
|
|
|
137
|
-
def exchange_code_for_tokens(code, verifier, return_to = "/")
|
|
149
|
+
def exchange_code_for_tokens(code, verifier, return_to = "/", expected_nonce = nil)
|
|
138
150
|
client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
|
|
139
151
|
client_secret = Shakha.config.google_client_secret || ENV["GOOGLE_CLIENT_SECRET"]
|
|
140
152
|
base_url = Shakha.config.service_base_url || "http://localhost:3000"
|
|
@@ -161,6 +173,11 @@ module Shakha
|
|
|
161
173
|
payload = decode_id_token(id_token)
|
|
162
174
|
google_sub = payload["sub"]
|
|
163
175
|
|
|
176
|
+
# Verify nonce (OIDC replay protection)
|
|
177
|
+
if expected_nonce && payload["nonce"] != expected_nonce
|
|
178
|
+
raise GoogleOAuthError, "Nonce mismatch"
|
|
179
|
+
end
|
|
180
|
+
|
|
164
181
|
client = find_or_create_client
|
|
165
182
|
pairwise_sub = Shakha.derive_pairwise_sub(google_sub, client.client_id)
|
|
166
183
|
|
|
@@ -178,7 +195,6 @@ module Shakha
|
|
|
178
195
|
session_record = Shakha::Session.create!(
|
|
179
196
|
user: user,
|
|
180
197
|
client: client,
|
|
181
|
-
jti: SecureRandom.uuid,
|
|
182
198
|
ip_address: request.remote_ip,
|
|
183
199
|
user_agent: request.user_agent
|
|
184
200
|
)
|
|
@@ -227,12 +243,24 @@ module Shakha
|
|
|
227
243
|
uri = URI.parse(url)
|
|
228
244
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
229
245
|
http.use_ssl = uri.scheme == "https"
|
|
246
|
+
http.open_timeout = 5
|
|
247
|
+
http.read_timeout = 10
|
|
230
248
|
|
|
231
249
|
request = Net::HTTP::Post.new(uri.request_uri)
|
|
232
250
|
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
233
251
|
request.body = URI.encode_www_form(body)
|
|
234
252
|
|
|
235
|
-
http.request(request)
|
|
253
|
+
response = http.request(request)
|
|
254
|
+
|
|
255
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
256
|
+
Rails.logger.error("[Shakha] Google API error: HTTP #{response.code} — #{response.body.truncate(500)}")
|
|
257
|
+
raise GoogleOAuthError, "Google returned HTTP #{response.code}"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
response
|
|
261
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, SocketError => e
|
|
262
|
+
Rails.logger.error("[Shakha] Network error contacting Google: #{e.message}")
|
|
263
|
+
raise GoogleOAuthError, "Unable to reach Google authentication service"
|
|
236
264
|
end
|
|
237
265
|
end
|
|
238
266
|
end
|
|
@@ -54,6 +54,13 @@ module Shakha
|
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
def list
|
|
58
|
+
return redirect_to "/auth/shakha" unless signed_in?
|
|
59
|
+
|
|
60
|
+
@sessions = current_user.sessions.active.order(created_at: :desc)
|
|
61
|
+
@current_token = current_session&.token
|
|
62
|
+
end
|
|
63
|
+
|
|
57
64
|
def revoke
|
|
58
65
|
return render json: { error: "Authentication required" }, status: :unauthorized unless signed_in?
|
|
59
66
|
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
<% content_for :title, "Authentication Error" %>
|
|
2
2
|
|
|
3
|
-
<div class="
|
|
4
|
-
<div class="
|
|
5
|
-
<div class="
|
|
6
|
-
<
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
<div class="sh-layout sh-animate-fade">
|
|
4
|
+
<div class="sh-card sh-animate-slide">
|
|
5
|
+
<div class="sh-error">
|
|
6
|
+
<div class="sh-error__icon">
|
|
7
|
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
8
|
+
<circle cx="12" cy="12" r="10"/>
|
|
9
|
+
<line x1="12" y1="8" x2="12" y2="12"/>
|
|
10
|
+
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
11
|
+
</svg>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<h1 class="sh-error__title">Something went wrong</h1>
|
|
15
|
+
<p class="sh-error__message"><%= @message || "We couldn't sign you in. Please try again." %></p>
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
<%= link_to "/auth/shakha", class: "sh-btn sh-btn--primary" do %>
|
|
18
|
+
<svg class="sh-btn__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
19
|
+
<polyline points="1 4 1 10 7 10"/>
|
|
20
|
+
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
|
|
21
|
+
</svg>
|
|
22
|
+
Try Again
|
|
23
|
+
<% end %>
|
|
15
24
|
|
|
16
|
-
|
|
25
|
+
<p class="sh-mt-4">
|
|
26
|
+
<a href="mailto:support@shakha.dev" class="sh-link sh-link--subtle">Contact Support</a>
|
|
27
|
+
</p>
|
|
28
|
+
</div>
|
|
17
29
|
</div>
|
|
18
30
|
</div>
|
|
@@ -1,16 +1,25 @@
|
|
|
1
|
-
<% content_for :title, "Sign
|
|
1
|
+
<% content_for :title, "Sign in — #{@client&.name || 'Shakha'}" %>
|
|
2
2
|
|
|
3
|
-
<div class="
|
|
4
|
-
<div class="
|
|
5
|
-
<div class="
|
|
6
|
-
<
|
|
3
|
+
<div class="sh-layout sh-animate-fade">
|
|
4
|
+
<div class="sh-card sh-animate-slide">
|
|
5
|
+
<div class="sh-card__header">
|
|
6
|
+
<div class="sh-brand">
|
|
7
|
+
<div class="sh-brand__icon">S</div>
|
|
8
|
+
<div>
|
|
9
|
+
<div class="sh-brand__name"><%= @client&.name || "Shakha" %></div>
|
|
10
|
+
<div class="sh-brand__subtitle">Secure authentication</div>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<h1 class="sh-heading sh-heading--center">Welcome back</h1>
|
|
15
|
+
<p class="sh-subheading">Sign in to continue to <%= @client&.name || "your app" %></p>
|
|
7
16
|
</div>
|
|
8
17
|
|
|
9
|
-
<div class="
|
|
18
|
+
<div class="sh-card__body">
|
|
10
19
|
<%= link_to shakha.authorize_path(request_pii: 1),
|
|
11
|
-
class: "
|
|
20
|
+
class: "sh-btn sh-btn--google",
|
|
12
21
|
data: { turbo: false } do %>
|
|
13
|
-
<svg class="
|
|
22
|
+
<svg class="sh-btn__icon" viewBox="0 0 18 18" aria-hidden="true">
|
|
14
23
|
<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
24
|
<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
25
|
<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"/>
|
|
@@ -19,11 +28,19 @@
|
|
|
19
28
|
Continue with Google
|
|
20
29
|
<% end %>
|
|
21
30
|
|
|
22
|
-
<
|
|
31
|
+
<div class="sh-divider">or</div>
|
|
32
|
+
|
|
33
|
+
<p class="sh-text-center" style="color: var(--sh-text-tertiary); font-size: var(--sh-text-sm);">
|
|
23
34
|
By signing in, you agree to our
|
|
24
|
-
|
|
25
|
-
|
|
35
|
+
<a href="#">Terms</a> and
|
|
36
|
+
<a href="#">Privacy Policy</a>.
|
|
26
37
|
</p>
|
|
27
38
|
</div>
|
|
39
|
+
|
|
40
|
+
<div class="sh-card__footer">
|
|
41
|
+
<span style="color: var(--sh-text-tertiary); font-size: var(--sh-text-sm);">
|
|
42
|
+
Secured by <strong style="color: var(--sh-text-secondary);">Shakha</strong>
|
|
43
|
+
</span>
|
|
44
|
+
</div>
|
|
28
45
|
</div>
|
|
29
|
-
</div>
|
|
46
|
+
</div>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<% content_for :title, "Active Sessions" %>
|
|
2
|
+
|
|
3
|
+
<div class="sh-layout sh-animate-fade">
|
|
4
|
+
<div class="sh-card sh-animate-slide" style="max-width: 560px;">
|
|
5
|
+
<div class="sh-card__header">
|
|
6
|
+
<div class="sh-brand">
|
|
7
|
+
<div class="sh-brand__icon">S</div>
|
|
8
|
+
<div>
|
|
9
|
+
<div class="sh-brand__name">Active Sessions</div>
|
|
10
|
+
<div class="sh-brand__subtitle">Manage your signed-in devices</div>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<div class="sh-sessions">
|
|
16
|
+
<div class="sh-sessions__header">
|
|
17
|
+
<span class="sh-sessions__title">Devices</span>
|
|
18
|
+
<span class="sh-sessions__count"><%= @sessions&.size || 0 %> active</span>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<% if @sessions&.any? %>
|
|
22
|
+
<% @sessions.each do |session| %>
|
|
23
|
+
<div class="sh-session <%= 'sh-session--current' if session.token == @current_token %>">
|
|
24
|
+
<div class="sh-session__info">
|
|
25
|
+
<div style="display: flex; align-items: center; gap: var(--sh-space-2);">
|
|
26
|
+
<% if session.token == @current_token %>
|
|
27
|
+
<span class="sh-session__badge">Current</span>
|
|
28
|
+
<% end %>
|
|
29
|
+
<span class="sh-session__device">Web Browser</span>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="sh-session__meta">
|
|
32
|
+
<%= session.ip_address || "Unknown IP" %> ·
|
|
33
|
+
<%= session.created_at.strftime("%b %d, %Y at %I:%M %p") %>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<% if session.token != @current_token %>
|
|
38
|
+
<%= button_to revoke_session_path(session.id),
|
|
39
|
+
method: :delete,
|
|
40
|
+
class: "sh-session__btn",
|
|
41
|
+
data: { turbo: false, confirm: "Revoke this session?" } do %>
|
|
42
|
+
Revoke
|
|
43
|
+
<% end %>
|
|
44
|
+
<% end %>
|
|
45
|
+
</div>
|
|
46
|
+
<% end %>
|
|
47
|
+
<% else %>
|
|
48
|
+
<div class="sh-empty">
|
|
49
|
+
<div class="sh-empty__icon">
|
|
50
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
51
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
|
52
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
|
53
|
+
</svg>
|
|
54
|
+
</div>
|
|
55
|
+
<p>No active sessions found.</p>
|
|
56
|
+
</div>
|
|
57
|
+
<% end %>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div class="sh-card__footer">
|
|
61
|
+
<%= link_to "/", class: "sh-link" do %>
|
|
62
|
+
← Back to app
|
|
63
|
+
<% end %>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html>
|
|
2
|
+
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
-
<
|
|
4
|
+
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title><%= yield(:title).presence || "Shakha" %></title>
|
|
6
7
|
<%= stylesheet_link_tag "shakha", "data-turbo-track": "reload" %>
|
|
7
8
|
<%= csrf_meta_tags %>
|
|
9
|
+
<meta name="theme-color" content="#0f0f23" media="(prefers-color-scheme: dark)">
|
|
10
|
+
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
|
8
11
|
</head>
|
|
9
12
|
<body>
|
|
10
13
|
<%= yield %>
|
|
11
14
|
</body>
|
|
12
|
-
</html>
|
|
15
|
+
</html>
|
data/lib/shakha/config.rb
CHANGED
data/lib/shakha/engine.rb
CHANGED
data/lib/shakha/jwt_handler.rb
CHANGED
|
@@ -89,7 +89,7 @@ module Shakha
|
|
|
89
89
|
return signing_key&.public_key if signing_key
|
|
90
90
|
|
|
91
91
|
public_material = Shakha.config.verification_key
|
|
92
|
-
return nil unless
|
|
92
|
+
return nil unless public_materiall
|
|
93
93
|
|
|
94
94
|
if public_material.start_with?("-----BEGIN")
|
|
95
95
|
OpenSSL::PKey::EC.new(public_material)
|
data/lib/shakha/pkce.rb
CHANGED
|
@@ -14,9 +14,7 @@ module Shakha
|
|
|
14
14
|
|
|
15
15
|
class << self
|
|
16
16
|
def generate_code_verifier
|
|
17
|
-
SecureRandom.urlsafe_base64(CODE_VERIFIER_LENGTH)
|
|
18
|
-
.tr("-_", "+/")
|
|
19
|
-
.slice(0, CODE_VERIFIER_LENGTH)
|
|
17
|
+
SecureRandom.urlsafe_base64(CODE_VERIFIER_LENGTH, padding: false)
|
|
20
18
|
end
|
|
21
19
|
|
|
22
20
|
def generate_code_challenge(verifier)
|
|
@@ -33,14 +31,16 @@ module Shakha
|
|
|
33
31
|
verifier = PKCEMixin.generate_code_verifier
|
|
34
32
|
challenge = PKCEMixin.generate_code_challenge(verifier)
|
|
35
33
|
state = SecureRandom.urlsafe_base64(32)
|
|
34
|
+
nonce = SecureRandom.urlsafe_base64(32)
|
|
36
35
|
return_to = params[:return_to] || "/"
|
|
37
36
|
|
|
38
37
|
pkce_record = {
|
|
39
38
|
verifier: verifier,
|
|
40
|
-
return_to: return_to
|
|
39
|
+
return_to: return_to,
|
|
40
|
+
nonce: nonce
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
cookies[PKCE_COOKIE_NAME] = {
|
|
43
|
+
cookies.encrypted[PKCE_COOKIE_NAME] = {
|
|
44
44
|
value: pkce_record.merge(state: state).to_json,
|
|
45
45
|
httponly: true,
|
|
46
46
|
secure: Rails.env.production?,
|
|
@@ -48,11 +48,11 @@ module Shakha
|
|
|
48
48
|
expires: Time.now.utc + PKCE_COOKIE_EXPIRY_SECONDS
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
{ challenge: challenge, state: state }
|
|
51
|
+
{ challenge: challenge, state: state, nonce: nonce }
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
def verify_pkce!(
|
|
55
|
-
pkce_json = cookies[PKCE_COOKIE_NAME]
|
|
54
|
+
def verify_pkce!(state_param)
|
|
55
|
+
pkce_json = cookies.encrypted[PKCE_COOKIE_NAME]
|
|
56
56
|
|
|
57
57
|
raise PKCEError, "No PKCE session found" unless pkce_json
|
|
58
58
|
|
|
@@ -63,23 +63,17 @@ module Shakha
|
|
|
63
63
|
stored_state = pkce_data[:state]
|
|
64
64
|
stored_verifier = pkce_data[:verifier]
|
|
65
65
|
stored_return_to = pkce_data[:return_to]
|
|
66
|
+
stored_nonce = pkce_data[:nonce]
|
|
66
67
|
|
|
67
68
|
cookies.delete(PKCE_COOKIE_NAME)
|
|
68
69
|
|
|
69
|
-
raise PKCEError, "State mismatch" unless stored_state
|
|
70
|
+
raise PKCEError, "State mismatch" unless ActiveSupport::SecurityUtils.secure_compare(stored_state.to_s, state_param.to_s)
|
|
70
71
|
|
|
71
|
-
|
|
72
|
-
code_challenge = params[:code_challenge]
|
|
73
|
-
|
|
74
|
-
if code_challenge.present?
|
|
75
|
-
raise PKCEError, "Invalid code verifier" unless computed == code_challenge
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
{ verifier: stored_verifier, return_to: stored_return_to }
|
|
72
|
+
{ verifier: stored_verifier, return_to: stored_return_to, nonce: stored_nonce }
|
|
79
73
|
end
|
|
80
74
|
|
|
81
75
|
def pkce_state
|
|
82
|
-
pkce_json = cookies[PKCE_COOKIE_NAME]
|
|
76
|
+
pkce_json = cookies.encrypted[PKCE_COOKIE_NAME]
|
|
83
77
|
return nil unless pkce_json
|
|
84
78
|
|
|
85
79
|
JSON.parse(pkce_json).with_indifferent_access
|
data/lib/shakha/rate_limiter.rb
CHANGED
|
@@ -23,16 +23,10 @@ module Shakha
|
|
|
23
23
|
return unless Shakha.config.rate_limiting_enabled
|
|
24
24
|
|
|
25
25
|
cache_key = "shakha-rate:#{key}:#{request.remote_ip}"
|
|
26
|
+
count = Rails.cache.increment(cache_key, 1, expires_in: period.seconds)
|
|
26
27
|
|
|
27
|
-
count
|
|
28
|
-
|
|
29
|
-
if count == 1
|
|
30
|
-
Rails.cache.write(cache_key, count, expires_in: period.seconds)
|
|
31
|
-
elsif count > max
|
|
28
|
+
if count > max
|
|
32
29
|
render json: { error: "Too many requests. Try again later." }, status: :too_many_requests
|
|
33
|
-
return
|
|
34
|
-
else
|
|
35
|
-
Rails.cache.write(cache_key, count, expires_in: period.seconds)
|
|
36
30
|
end
|
|
37
31
|
end
|
|
38
32
|
end
|
data/lib/shakha/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: shakha
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Asrat
|
|
@@ -90,6 +90,7 @@ files:
|
|
|
90
90
|
- app/views/shakha/auth/callback.html.erb
|
|
91
91
|
- app/views/shakha/auth/error.html.erb
|
|
92
92
|
- app/views/shakha/auth/new.html.erb
|
|
93
|
+
- app/views/shakha/auth/sessions.html.erb
|
|
93
94
|
- app/views/shakha/errors/show.html.erb
|
|
94
95
|
- app/views/shakha/layouts/shakha.html.erb
|
|
95
96
|
- lib/generators/shakha/install_generator.rb
|