Skip to content

Commit b486213

Browse files
fix(auth): harden key copy and audit method logging
1 parent adc6cfd commit b486213

9 files changed

Lines changed: 280 additions & 9 deletions

File tree

internal/admin/dashboard/static/js/modules/auth-keys.js

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
authKeyIssuedValue: '',
1212
authKeyDeactivatingID: '',
1313
authKeyCopied: false,
14+
authKeyCopyError: false,
15+
authKeyCopyResetTimer: null,
1416
authKeyForm: {
1517
name: '',
1618
description: '',
@@ -52,27 +54,103 @@
5254
this.authKeyError = '';
5355
this.authKeyNotice = '';
5456
this.authKeyIssuedValue = '';
57+
this.resetAuthKeyCopyFeedback();
5558
this.authKeyForm = this.defaultAuthKeyForm();
5659
},
5760

5861
closeAuthKeyForm() {
5962
this.authKeyFormOpen = false;
6063
this.authKeyError = '';
6164
this.authKeyIssuedValue = '';
62-
this.authKeyCopied = false;
65+
this.resetAuthKeyCopyFeedback();
6366
this.authKeyForm = this.defaultAuthKeyForm();
6467
},
6568

69+
clearAuthKeyCopyResetTimer() {
70+
if (this.authKeyCopyResetTimer !== null) {
71+
clearTimeout(this.authKeyCopyResetTimer);
72+
this.authKeyCopyResetTimer = null;
73+
}
74+
},
75+
76+
scheduleAuthKeyCopyFeedbackReset() {
77+
this.clearAuthKeyCopyResetTimer();
78+
this.authKeyCopyResetTimer = setTimeout(() => {
79+
this.authKeyCopied = false;
80+
this.authKeyCopyError = false;
81+
this.authKeyCopyResetTimer = null;
82+
}, 2000);
83+
},
84+
85+
resetAuthKeyCopyFeedback() {
86+
this.clearAuthKeyCopyResetTimer();
87+
this.authKeyCopied = false;
88+
this.authKeyCopyError = false;
89+
},
90+
91+
setAuthKeyCopyFeedback(copied, hasError) {
92+
this.authKeyCopied = copied;
93+
this.authKeyCopyError = hasError;
94+
this.scheduleAuthKeyCopyFeedbackReset();
95+
},
96+
6697
copyAuthKeyValue() {
67-
navigator.clipboard.writeText(this.authKeyIssuedValue).then(() => {
68-
this.authKeyCopied = true;
69-
setTimeout(() => { this.authKeyCopied = false; }, 2000);
70-
});
98+
const value = String(this.authKeyIssuedValue || '');
99+
const clipboard = global.navigator && global.navigator.clipboard;
100+
101+
this.resetAuthKeyCopyFeedback();
102+
103+
if (clipboard && typeof clipboard.writeText === 'function') {
104+
return clipboard.writeText(value).then(() => {
105+
this.setAuthKeyCopyFeedback(true, false);
106+
}).catch((error) => {
107+
console.error('Failed to copy auth key:', error);
108+
this.setAuthKeyCopyFeedback(false, true);
109+
});
110+
}
111+
112+
const doc = global.document;
113+
if (!doc || !doc.body || typeof doc.createElement !== 'function' || typeof doc.execCommand !== 'function') {
114+
this.setAuthKeyCopyFeedback(false, true);
115+
return Promise.resolve();
116+
}
117+
118+
const textarea = doc.createElement('textarea');
119+
textarea.value = value;
120+
textarea.setAttribute('readonly', '');
121+
textarea.style.position = 'fixed';
122+
textarea.style.top = '0';
123+
textarea.style.left = '0';
124+
textarea.style.opacity = '0';
125+
126+
try {
127+
doc.body.appendChild(textarea);
128+
if (typeof textarea.focus === 'function') {
129+
textarea.focus();
130+
}
131+
if (typeof textarea.select === 'function') {
132+
textarea.select();
133+
}
134+
if (typeof textarea.setSelectionRange === 'function') {
135+
textarea.setSelectionRange(0, textarea.value.length);
136+
}
137+
const copied = !!doc.execCommand('copy');
138+
this.setAuthKeyCopyFeedback(copied, !copied);
139+
} catch (error) {
140+
console.error('Failed to copy auth key:', error);
141+
this.setAuthKeyCopyFeedback(false, true);
142+
} finally {
143+
if (textarea.parentNode) {
144+
textarea.parentNode.removeChild(textarea);
145+
}
146+
}
147+
148+
return Promise.resolve();
71149
},
72150

73151
dismissIssuedKey() {
74152
this.authKeyIssuedValue = '';
75-
this.authKeyCopied = false;
153+
this.resetAuthKeyCopyFeedback();
76154
this.authKeyForm = this.defaultAuthKeyForm();
77155
},
78156

internal/admin/dashboard/static/js/modules/auth-keys.test.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,26 @@ function createAuthKeysModule(overrides) {
2626
return factory();
2727
}
2828

29+
function createTimerHarness() {
30+
let nextID = 1;
31+
const timers = new Map();
32+
return {
33+
setTimeout(callback, _delay) {
34+
const id = nextID++;
35+
timers.set(id, callback);
36+
return id;
37+
},
38+
clearTimeout(id) {
39+
timers.delete(id);
40+
},
41+
runAll() {
42+
const callbacks = Array.from(timers.values());
43+
timers.clear();
44+
callbacks.forEach((callback) => callback());
45+
}
46+
};
47+
}
48+
2949
test('submitAuthKeyForm serializes date-only expirations to the end of the selected UTC day', async () => {
3050
const requests = [];
3151
const module = createAuthKeysModule({
@@ -57,3 +77,118 @@ test('submitAuthKeyForm serializes date-only expirations to the end of the selec
5777
'2026-04-01T23:59:59Z'
5878
);
5979
});
80+
81+
test('copyAuthKeyValue uses navigator.clipboard when available and resets feedback', async () => {
82+
const timers = createTimerHarness();
83+
const writes = [];
84+
const module = createAuthKeysModule({
85+
setTimeout: timers.setTimeout,
86+
clearTimeout: timers.clearTimeout,
87+
window: {
88+
navigator: {
89+
clipboard: {
90+
writeText(value) {
91+
writes.push(value);
92+
return Promise.resolve();
93+
}
94+
}
95+
}
96+
}
97+
});
98+
99+
module.authKeyIssuedValue = 'sk_gom_test';
100+
101+
await module.copyAuthKeyValue();
102+
103+
assert.deepEqual(writes, ['sk_gom_test']);
104+
assert.equal(module.authKeyCopied, true);
105+
assert.equal(module.authKeyCopyError, false);
106+
107+
timers.runAll();
108+
109+
assert.equal(module.authKeyCopied, false);
110+
assert.equal(module.authKeyCopyError, false);
111+
});
112+
113+
test('copyAuthKeyValue sets an error flag when navigator.clipboard rejects', async () => {
114+
const timers = createTimerHarness();
115+
const module = createAuthKeysModule({
116+
console: {
117+
error() {}
118+
},
119+
setTimeout: timers.setTimeout,
120+
clearTimeout: timers.clearTimeout,
121+
window: {
122+
navigator: {
123+
clipboard: {
124+
writeText() {
125+
return Promise.reject(new Error('denied'));
126+
}
127+
}
128+
}
129+
}
130+
});
131+
132+
module.authKeyIssuedValue = 'sk_gom_test';
133+
134+
await module.copyAuthKeyValue();
135+
136+
assert.equal(module.authKeyCopied, false);
137+
assert.equal(module.authKeyCopyError, true);
138+
139+
timers.runAll();
140+
141+
assert.equal(module.authKeyCopied, false);
142+
assert.equal(module.authKeyCopyError, false);
143+
});
144+
145+
test('copyAuthKeyValue falls back to document.execCommand when clipboard API is unavailable', async () => {
146+
const timers = createTimerHarness();
147+
const appended = [];
148+
const removed = [];
149+
const fakeBody = {
150+
appendChild(node) {
151+
node.parentNode = fakeBody;
152+
appended.push(node);
153+
},
154+
removeChild(node) {
155+
removed.push(node);
156+
node.parentNode = null;
157+
}
158+
};
159+
const fakeDocument = {
160+
body: fakeBody,
161+
createElement() {
162+
return {
163+
value: '',
164+
style: {},
165+
setAttribute() {},
166+
focus() {},
167+
select() {},
168+
setSelectionRange() {},
169+
parentNode: null
170+
};
171+
},
172+
execCommand(command) {
173+
assert.equal(command, 'copy');
174+
return true;
175+
}
176+
};
177+
const module = createAuthKeysModule({
178+
setTimeout: timers.setTimeout,
179+
clearTimeout: timers.clearTimeout,
180+
window: {
181+
document: fakeDocument
182+
}
183+
});
184+
185+
module.authKeyIssuedValue = 'sk_gom_test';
186+
187+
await module.copyAuthKeyValue();
188+
189+
assert.equal(appended.length, 1);
190+
assert.equal(removed.length, 1);
191+
assert.equal(appended[0].value, 'sk_gom_test');
192+
assert.equal(module.authKeyCopied, true);
193+
assert.equal(module.authKeyCopyError, false);
194+
});

internal/admin/dashboard/templates/index.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,9 @@ <h3>Create API Key</h3>
282282
<span x-text="authKeyCopied ? 'Copied' : 'Copy'"></span>
283283
</button>
284284
</div>
285+
<p class="alias-form-error" x-show="authKeyCopyError">
286+
Unable to copy the key automatically. Copy it manually.
287+
</p>
285288
<div class="alias-form-actions">
286289
<button type="button" class="pagination-btn pagination-btn-primary"
287290
@click="dismissIssuedKey()">Done, I&rsquo;ve stored it</button>

internal/auditlog/middleware.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,12 @@ func EnrichEntryWithAuthMethod(c *echo.Context, method string) {
398398
if !ok || entry == nil {
399399
return
400400
}
401+
method = strings.ToLower(strings.TrimSpace(method))
402+
switch method {
403+
case AuthMethodAPIKey, AuthMethodMasterKey, AuthMethodNoKey, "unknown":
404+
default:
405+
return
406+
}
401407
entry.AuthMethod = method
402408
}
403409

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package auditlog
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/labstack/echo/v5"
9+
)
10+
11+
func TestEnrichEntryWithAuthMethodTrimsAndValidatesAllowedValues(t *testing.T) {
12+
e := echo.New()
13+
req := httptest.NewRequest(http.MethodGet, "/", nil)
14+
rec := httptest.NewRecorder()
15+
c := e.NewContext(req, rec)
16+
entry := &LogEntry{}
17+
c.Set(string(LogEntryKey), entry)
18+
19+
EnrichEntryWithAuthMethod(c, " API_KEY ")
20+
if entry.AuthMethod != AuthMethodAPIKey {
21+
t.Fatalf("entry auth method = %q, want %q", entry.AuthMethod, AuthMethodAPIKey)
22+
}
23+
24+
EnrichEntryWithAuthMethod(c, "master_key")
25+
if entry.AuthMethod != AuthMethodMasterKey {
26+
t.Fatalf("entry auth method = %q, want %q", entry.AuthMethod, AuthMethodMasterKey)
27+
}
28+
}
29+
30+
func TestEnrichEntryWithAuthMethodIgnoresBlankAndUnsupportedValues(t *testing.T) {
31+
e := echo.New()
32+
req := httptest.NewRequest(http.MethodGet, "/", nil)
33+
rec := httptest.NewRecorder()
34+
c := e.NewContext(req, rec)
35+
entry := &LogEntry{}
36+
c.Set(string(LogEntryKey), entry)
37+
38+
EnrichEntryWithAuthMethod(c, " ")
39+
if entry.AuthMethod != "" {
40+
t.Fatalf("entry auth method = %q, want empty", entry.AuthMethod)
41+
}
42+
43+
EnrichEntryWithAuthMethod(c, "oauth")
44+
if entry.AuthMethod != "" {
45+
t.Fatalf("entry auth method = %q, want empty", entry.AuthMethod)
46+
}
47+
}

internal/auditlog/store_postgresql.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,6 @@ func NewPostgreSQLStore(pool *pgxpool.Pool, retentionDays int) (*PostgreSQLStore
8787
"ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS auth_key_id TEXT",
8888
"ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS auth_method TEXT",
8989
"ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS user_path TEXT",
90-
"ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS auth_method TEXT",
9190
}
9291
for _, migration := range migrations {
9392
if _, err := pool.Exec(ctx, migration); err != nil {

internal/auditlog/store_sqlite.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ func NewSQLiteStore(db *sql.DB, retentionDays int) (*SQLiteStore, error) {
7272
"ALTER TABLE audit_logs ADD COLUMN auth_key_id TEXT",
7373
"ALTER TABLE audit_logs ADD COLUMN auth_method TEXT",
7474
"ALTER TABLE audit_logs ADD COLUMN user_path TEXT",
75-
"ALTER TABLE audit_logs ADD COLUMN auth_method TEXT",
7675
}
7776
for _, migration := range migrations {
7877
if _, err := db.Exec(migration); err != nil {

internal/server/auth.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,12 @@ func AuthMiddlewareWithAuthenticator(masterKey string, authenticator BearerToken
7272
}
7373

7474
if authenticator != nil && authenticator.Enabled() {
75+
auditlog.EnrichEntryWithAuthMethod(c, auditlog.AuthMethodAPIKey)
7576
authKeyID, err := authenticator.Authenticate(c.Request().Context(), token)
7677
if err == nil {
7778
ctx := core.WithAuthKeyID(c.Request().Context(), authKeyID)
7879
c.SetRequest(c.Request().WithContext(ctx))
7980
auditlog.EnrichEntryWithAuthKeyID(c, authKeyID)
80-
auditlog.EnrichEntryWithAuthMethod(c, auditlog.AuthMethodAPIKey)
8181
return next(c)
8282
}
8383

internal/server/auth_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,9 @@ func TestAuthMiddlewareWithAuthenticator_ManagedKeyEnrichesContextAndAudit(t *te
189189
if entry.AuthKeyID != "key-123" {
190190
t.Fatalf("audit entry auth key id = %q, want key-123", entry.AuthKeyID)
191191
}
192+
if entry.AuthMethod != auditlog.AuthMethodAPIKey {
193+
t.Fatalf("audit entry auth method = %q, want %q", entry.AuthMethod, auditlog.AuthMethodAPIKey)
194+
}
192195
return c.String(http.StatusOK, "ok")
193196
}
194197

@@ -235,6 +238,7 @@ func TestAuthMiddlewareWithAuthenticator_ManagedKeyFailureUsesGenericClientMessa
235238
require.True(t, ok)
236239
require.NotNil(t, entry)
237240
require.NotNil(t, entry.Data)
241+
assert.Equal(t, auditlog.AuthMethodAPIKey, entry.AuthMethod)
238242
assert.Equal(t, string(core.ErrorTypeAuthentication), entry.ErrorType)
239243
assert.Equal(t, "authentication unavailable", entry.Data.ErrorMessage)
240244
}

0 commit comments

Comments
 (0)