@alliance-droid/svelte-auth-core 1.0.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.
- package/dist/adapter-context.d.ts +19 -0
- package/dist/adapter-context.d.ts.map +1 -0
- package/dist/adapter-context.js +68 -0
- package/dist/adapter-context.js.map +1 -0
- package/dist/adapters/__tests__/adapter-tests.d.ts +7 -0
- package/dist/adapters/__tests__/adapter-tests.d.ts.map +1 -0
- package/dist/adapters/__tests__/adapter-tests.js +206 -0
- package/dist/adapters/__tests__/adapter-tests.js.map +1 -0
- package/dist/adapters/adapter.d.ts +60 -0
- package/dist/adapters/adapter.d.ts.map +1 -0
- package/dist/adapters/adapter.js +2 -0
- package/dist/adapters/adapter.js.map +1 -0
- package/dist/adapters/filesystem-adapter.d.ts +26 -0
- package/dist/adapters/filesystem-adapter.d.ts.map +1 -0
- package/dist/adapters/filesystem-adapter.js +148 -0
- package/dist/adapters/filesystem-adapter.js.map +1 -0
- package/dist/adapters/index.d.ts +6 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +5 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/mongodb-adapter.d.ts +27 -0
- package/dist/adapters/mongodb-adapter.d.ts.map +1 -0
- package/dist/adapters/mongodb-adapter.js +213 -0
- package/dist/adapters/mongodb-adapter.js.map +1 -0
- package/dist/adapters/postgres-adapter.d.ts +30 -0
- package/dist/adapters/postgres-adapter.d.ts.map +1 -0
- package/dist/adapters/postgres-adapter.js +237 -0
- package/dist/adapters/postgres-adapter.js.map +1 -0
- package/dist/adapters/sqlite-adapter.d.ts +26 -0
- package/dist/adapters/sqlite-adapter.d.ts.map +1 -0
- package/dist/adapters/sqlite-adapter.js +261 -0
- package/dist/adapters/sqlite-adapter.js.map +1 -0
- package/dist/auth.d.ts +48 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +205 -0
- package/dist/auth.js.map +1 -0
- package/dist/client-jwt.d.ts +30 -0
- package/dist/client-jwt.d.ts.map +1 -0
- package/dist/client-jwt.js +57 -0
- package/dist/client-jwt.js.map +1 -0
- package/dist/client-store.d.ts +31 -0
- package/dist/client-store.d.ts.map +1 -0
- package/dist/client-store.js +122 -0
- package/dist/client-store.js.map +1 -0
- package/dist/cors.d.ts +48 -0
- package/dist/cors.d.ts.map +1 -0
- package/dist/cors.js +88 -0
- package/dist/cors.js.map +1 -0
- package/dist/csrf.d.ts +57 -0
- package/dist/csrf.d.ts.map +1 -0
- package/dist/csrf.js +95 -0
- package/dist/csrf.js.map +1 -0
- package/dist/db.d.ts +22 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +43 -0
- package/dist/db.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/input-validation.d.ts +78 -0
- package/dist/input-validation.d.ts.map +1 -0
- package/dist/input-validation.js +238 -0
- package/dist/input-validation.js.map +1 -0
- package/dist/oauth-callback.d.ts +31 -0
- package/dist/oauth-callback.d.ts.map +1 -0
- package/dist/oauth-callback.js +254 -0
- package/dist/oauth-callback.js.map +1 -0
- package/dist/oauth-providers.d.ts +92 -0
- package/dist/oauth-providers.d.ts.map +1 -0
- package/dist/oauth-providers.js +213 -0
- package/dist/oauth-providers.js.map +1 -0
- package/dist/oauth-types.d.ts +77 -0
- package/dist/oauth-types.d.ts.map +1 -0
- package/dist/oauth-types.js +2 -0
- package/dist/oauth-types.js.map +1 -0
- package/dist/password.d.ts +31 -0
- package/dist/password.d.ts.map +1 -0
- package/dist/password.js +54 -0
- package/dist/password.js.map +1 -0
- package/dist/providers/github-oauth.d.ts +58 -0
- package/dist/providers/github-oauth.d.ts.map +1 -0
- package/dist/providers/github-oauth.js +230 -0
- package/dist/providers/github-oauth.js.map +1 -0
- package/dist/providers/google-oauth.d.ts +46 -0
- package/dist/providers/google-oauth.d.ts.map +1 -0
- package/dist/providers/google-oauth.js +177 -0
- package/dist/providers/google-oauth.js.map +1 -0
- package/dist/providers/oidc-oauth.d.ts +85 -0
- package/dist/providers/oidc-oauth.d.ts.map +1 -0
- package/dist/providers/oidc-oauth.js +301 -0
- package/dist/providers/oidc-oauth.js.map +1 -0
- package/dist/rate-limit.d.ts +36 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +88 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/rate-limiting.d.ts +113 -0
- package/dist/rate-limiting.d.ts.map +1 -0
- package/dist/rate-limiting.js +221 -0
- package/dist/rate-limiting.js.map +1 -0
- package/dist/security-headers.d.ts +54 -0
- package/dist/security-headers.d.ts.map +1 -0
- package/dist/security-headers.js +123 -0
- package/dist/security-headers.js.map +1 -0
- package/dist/session.d.ts +13 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +33 -0
- package/dist/session.js.map +1 -0
- package/dist/sql-injection-prevention.d.ts +94 -0
- package/dist/sql-injection-prevention.d.ts.map +1 -0
- package/dist/sql-injection-prevention.js +222 -0
- package/dist/sql-injection-prevention.js.map +1 -0
- package/dist/token.d.ts +22 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +31 -0
- package/dist/token.js.map +1 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/user.d.ts +33 -0
- package/dist/user.d.ts.map +1 -0
- package/dist/user.js +144 -0
- package/dist/user.js.map +1 -0
- package/package.json +48 -0
- package/src/adapter-context.ts +72 -0
- package/src/adapters/__tests__/adapter-tests.ts +254 -0
- package/src/adapters/__tests__/filesystem-adapter.test.ts +48 -0
- package/src/adapters/__tests__/mongodb-adapter.test.ts +64 -0
- package/src/adapters/__tests__/postgres-adapter.test.ts +62 -0
- package/src/adapters/__tests__/sqlite-adapter.test.ts +103 -0
- package/src/adapters/__tests__/test-fs-adapter.json +4 -0
- package/src/adapters/adapter.ts +72 -0
- package/src/adapters/filesystem-adapter.ts +153 -0
- package/src/adapters/index.ts +5 -0
- package/src/adapters/mongodb-adapter.ts +208 -0
- package/src/adapters/postgres-adapter.ts +261 -0
- package/src/adapters/sqlite-adapter.ts +284 -0
- package/src/auth.ts +239 -0
- package/src/client-jwt.test.ts +137 -0
- package/src/client-jwt.ts +67 -0
- package/src/client-store.test.ts +149 -0
- package/src/client-store.ts +144 -0
- package/src/cors.test.ts +175 -0
- package/src/cors.ts +115 -0
- package/src/csrf.test.ts +226 -0
- package/src/csrf.ts +126 -0
- package/src/db.ts +57 -0
- package/src/index.ts +143 -0
- package/src/input-validation.test.ts +347 -0
- package/src/input-validation.ts +307 -0
- package/src/integration.test.ts +322 -0
- package/src/oauth-callback.test.ts +282 -0
- package/src/oauth-callback.ts +323 -0
- package/src/oauth-providers.ts +232 -0
- package/src/oauth-types.ts +82 -0
- package/src/password.test.ts +89 -0
- package/src/password.ts +62 -0
- package/src/providers/github-oauth.test.ts +290 -0
- package/src/providers/github-oauth.ts +226 -0
- package/src/providers/google-oauth.test.ts +240 -0
- package/src/providers/google-oauth.ts +166 -0
- package/src/providers/oidc-oauth.test.ts +367 -0
- package/src/providers/oidc-oauth.ts +302 -0
- package/src/rate-limit.test.ts +308 -0
- package/src/rate-limit.ts +118 -0
- package/src/rate-limiting.test.ts +390 -0
- package/src/rate-limiting.ts +275 -0
- package/src/security-headers.test.ts +242 -0
- package/src/security-headers.ts +160 -0
- package/src/security-penetration.test.ts +705 -0
- package/src/session.ts +42 -0
- package/src/sql-injection-prevention.test.ts +337 -0
- package/src/sql-injection-prevention.ts +272 -0
- package/src/token.test.ts +67 -0
- package/src/token.ts +34 -0
- package/src/types.ts +87 -0
- package/src/user.ts +165 -0
package/src/password.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as bcrypt from 'bcryptjs';
|
|
2
|
+
|
|
3
|
+
const BCRYPT_ROUNDS = 10;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hash a password using bcrypt
|
|
7
|
+
* @param password - Plain text password
|
|
8
|
+
* @returns Hashed password
|
|
9
|
+
*/
|
|
10
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
11
|
+
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Verify a password against a hash
|
|
16
|
+
* @param password - Plain text password
|
|
17
|
+
* @param hash - Hashed password
|
|
18
|
+
* @returns True if password matches, false otherwise
|
|
19
|
+
*/
|
|
20
|
+
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
|
21
|
+
return bcrypt.compare(password, hash);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validate password strength
|
|
26
|
+
* Requirements:
|
|
27
|
+
* - At least 8 characters
|
|
28
|
+
* - At least one uppercase letter
|
|
29
|
+
* - At least one lowercase letter
|
|
30
|
+
* - At least one number
|
|
31
|
+
* @param password - Password to validate
|
|
32
|
+
* @returns Error message if invalid, undefined if valid
|
|
33
|
+
*/
|
|
34
|
+
export function validatePasswordStrength(password: string): string | undefined {
|
|
35
|
+
if (password.length < 8) {
|
|
36
|
+
return 'Password must be at least 8 characters long';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!/[A-Z]/.test(password)) {
|
|
40
|
+
return 'Password must contain at least one uppercase letter';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!/[a-z]/.test(password)) {
|
|
44
|
+
return 'Password must contain at least one lowercase letter';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!/[0-9]/.test(password)) {
|
|
48
|
+
return 'Password must contain at least one number';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate email format
|
|
56
|
+
* @param email - Email to validate
|
|
57
|
+
* @returns True if valid, false otherwise
|
|
58
|
+
*/
|
|
59
|
+
export function isValidEmail(email: string): boolean {
|
|
60
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
61
|
+
return emailRegex.test(email) && email.length <= 254;
|
|
62
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { GitHubOAuthProvider } from './github-oauth';
|
|
3
|
+
|
|
4
|
+
describe('GitHubOAuthProvider', () => {
|
|
5
|
+
let provider: GitHubOAuthProvider;
|
|
6
|
+
const mockConfig = {
|
|
7
|
+
clientId: 'test-client-id',
|
|
8
|
+
clientSecret: 'test-client-secret',
|
|
9
|
+
redirectUri: 'http://localhost:3000/auth/github/callback'
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
provider = new GitHubOAuthProvider(mockConfig);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('constructor', () => {
|
|
17
|
+
it('should create provider with valid config', () => {
|
|
18
|
+
expect(provider).toBeDefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should set default scopes', () => {
|
|
22
|
+
const url = provider.generateAuthorizationUrl('state');
|
|
23
|
+
expect(url).toContain('scope=');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should support custom scopes', () => {
|
|
27
|
+
const customProvider = new GitHubOAuthProvider({
|
|
28
|
+
...mockConfig,
|
|
29
|
+
scope: ['read:user', 'user:email', 'repo']
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const url = customProvider.generateAuthorizationUrl('state');
|
|
33
|
+
expect(url).toContain('read%3Auser');
|
|
34
|
+
expect(url).toContain('user%3Aemail');
|
|
35
|
+
expect(url).toContain('repo');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should throw error when clientId is missing', () => {
|
|
39
|
+
expect(() => {
|
|
40
|
+
new GitHubOAuthProvider({
|
|
41
|
+
clientId: '',
|
|
42
|
+
clientSecret: 'secret',
|
|
43
|
+
redirectUri: 'http://localhost:3000/callback'
|
|
44
|
+
});
|
|
45
|
+
}).toThrow('GitHub OAuth configuration is missing required fields');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should throw error when clientSecret is missing', () => {
|
|
49
|
+
expect(() => {
|
|
50
|
+
new GitHubOAuthProvider({
|
|
51
|
+
clientId: 'id',
|
|
52
|
+
clientSecret: '',
|
|
53
|
+
redirectUri: 'http://localhost:3000/callback'
|
|
54
|
+
});
|
|
55
|
+
}).toThrow('GitHub OAuth configuration is missing required fields');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should throw error when redirectUri is missing', () => {
|
|
59
|
+
expect(() => {
|
|
60
|
+
new GitHubOAuthProvider({
|
|
61
|
+
clientId: 'id',
|
|
62
|
+
clientSecret: 'secret',
|
|
63
|
+
redirectUri: ''
|
|
64
|
+
});
|
|
65
|
+
}).toThrow('GitHub OAuth configuration is missing required fields');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('generateAuthorizationUrl', () => {
|
|
70
|
+
it('should generate valid authorization URL', () => {
|
|
71
|
+
const state = 'test-state';
|
|
72
|
+
const url = provider.generateAuthorizationUrl(state);
|
|
73
|
+
|
|
74
|
+
expect(url).toContain('https://github.com/login/oauth/authorize');
|
|
75
|
+
expect(url).toContain('client_id=test-client-id');
|
|
76
|
+
expect(url).toContain('redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fgithub%2Fcallback');
|
|
77
|
+
expect(url).toContain('state=test-state');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should allow signup by default', () => {
|
|
81
|
+
const url = provider.generateAuthorizationUrl('state');
|
|
82
|
+
expect(url).toContain('allow_signup=true');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should disable signup when configured', () => {
|
|
86
|
+
const customProvider = new GitHubOAuthProvider({
|
|
87
|
+
...mockConfig,
|
|
88
|
+
allowSignup: false
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const url = customProvider.generateAuthorizationUrl('state');
|
|
92
|
+
expect(url).toContain('allow_signup=false');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('validateAuthorizationCode', () => {
|
|
97
|
+
it('should validate valid authorization code', () => {
|
|
98
|
+
const code = 'valid-code-abc123';
|
|
99
|
+
expect(provider.validateAuthorizationCode(code)).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should reject empty code', () => {
|
|
103
|
+
expect(provider.validateAuthorizationCode('')).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should reject null/undefined code', () => {
|
|
107
|
+
expect(provider.validateAuthorizationCode(null as any)).toBe(false);
|
|
108
|
+
expect(provider.validateAuthorizationCode(undefined as any)).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('validateAccessToken', () => {
|
|
113
|
+
it('should validate valid access token', () => {
|
|
114
|
+
const token = 'ghu_valid-token-123';
|
|
115
|
+
expect(provider.validateAccessToken(token)).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should reject empty token', () => {
|
|
119
|
+
expect(provider.validateAccessToken('')).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should reject null/undefined token', () => {
|
|
123
|
+
expect(provider.validateAccessToken(null as any)).toBe(false);
|
|
124
|
+
expect(provider.validateAccessToken(undefined as any)).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('exchangeCode', () => {
|
|
129
|
+
it('should exchange code for access token', async () => {
|
|
130
|
+
const mockResponse = {
|
|
131
|
+
ok: true,
|
|
132
|
+
json: vi.fn().mockResolvedValue({
|
|
133
|
+
access_token: 'ghu_test-token-123',
|
|
134
|
+
token_type: 'bearer',
|
|
135
|
+
scope: 'read:user,user:email',
|
|
136
|
+
expires_in: 28800
|
|
137
|
+
})
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
global.fetch = vi.fn().mockResolvedValue(mockResponse);
|
|
141
|
+
|
|
142
|
+
const result = await provider.exchangeCode('test-code');
|
|
143
|
+
|
|
144
|
+
expect(result.accessToken).toBe('ghu_test-token-123');
|
|
145
|
+
expect(result.tokenType).toBe('bearer');
|
|
146
|
+
expect(result.expiresIn).toBe(28800);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should handle GitHub error response', async () => {
|
|
150
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
151
|
+
ok: true,
|
|
152
|
+
json: vi.fn().mockResolvedValue({
|
|
153
|
+
error: 'bad_verification_code',
|
|
154
|
+
error_description: 'The code passed is incorrect or expired.'
|
|
155
|
+
})
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await expect(provider.exchangeCode('invalid-code')).rejects.toThrow('GitHub OAuth error');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should throw error on failed exchange', async () => {
|
|
162
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
163
|
+
ok: false,
|
|
164
|
+
statusText: 'Unauthorized'
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await expect(provider.exchangeCode('test-code')).rejects.toThrow(
|
|
168
|
+
'GitHub token exchange failed'
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('fetchUserProfile', () => {
|
|
174
|
+
it('should fetch user profile with public email', async () => {
|
|
175
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
176
|
+
ok: true,
|
|
177
|
+
json: vi.fn().mockResolvedValue({
|
|
178
|
+
id: 123456,
|
|
179
|
+
login: 'testuser',
|
|
180
|
+
email: 'user@example.com',
|
|
181
|
+
name: 'Test User',
|
|
182
|
+
avatar_url: 'https://avatars.githubusercontent.com/u/123456'
|
|
183
|
+
})
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const result = await provider.fetchUserProfile('test-token');
|
|
187
|
+
|
|
188
|
+
expect(result.id).toBe('123456');
|
|
189
|
+
expect(result.email).toBe('user@example.com');
|
|
190
|
+
expect(result.name).toBe('Test User');
|
|
191
|
+
expect(result.avatar).toBe('https://avatars.githubusercontent.com/u/123456');
|
|
192
|
+
expect(result.provider).toBe('github');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should fetch email from emails endpoint when not in profile', async () => {
|
|
196
|
+
const fetchMock = vi.fn();
|
|
197
|
+
fetchMock.mockResolvedValueOnce({
|
|
198
|
+
ok: true,
|
|
199
|
+
json: vi.fn().mockResolvedValue({
|
|
200
|
+
id: 123456,
|
|
201
|
+
login: 'testuser',
|
|
202
|
+
email: null,
|
|
203
|
+
name: 'Test User',
|
|
204
|
+
avatar_url: 'https://avatars.githubusercontent.com/u/123456'
|
|
205
|
+
})
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
fetchMock.mockResolvedValueOnce({
|
|
209
|
+
ok: true,
|
|
210
|
+
json: vi.fn().mockResolvedValue([
|
|
211
|
+
{
|
|
212
|
+
email: 'primary@example.com',
|
|
213
|
+
primary: true,
|
|
214
|
+
verified: true,
|
|
215
|
+
visibility: 'public'
|
|
216
|
+
}
|
|
217
|
+
])
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
global.fetch = fetchMock;
|
|
221
|
+
|
|
222
|
+
const result = await provider.fetchUserProfile('test-token');
|
|
223
|
+
|
|
224
|
+
expect(result.email).toBe('primary@example.com');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should throw error when email cannot be retrieved', async () => {
|
|
228
|
+
global.fetch = vi.fn();
|
|
229
|
+
|
|
230
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
231
|
+
ok: true,
|
|
232
|
+
json: vi.fn().mockResolvedValue({
|
|
233
|
+
id: 123456,
|
|
234
|
+
login: 'testuser',
|
|
235
|
+
email: null,
|
|
236
|
+
name: 'Test User'
|
|
237
|
+
})
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
241
|
+
ok: false,
|
|
242
|
+
statusText: 'Unauthorized'
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await expect(provider.fetchUserProfile('invalid-token')).rejects.toThrow(
|
|
246
|
+
'Could not retrieve email from GitHub'
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should throw error on network failure', async () => {
|
|
251
|
+
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
|
|
252
|
+
|
|
253
|
+
await expect(provider.fetchUserProfile('test-token')).rejects.toThrow(
|
|
254
|
+
'Failed to fetch GitHub user profile'
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('refreshAccessToken', () => {
|
|
260
|
+
it('should return same token (GitHub tokens do not expire)', async () => {
|
|
261
|
+
const token = 'ghu_test-token-123';
|
|
262
|
+
const result = await provider.refreshAccessToken(token);
|
|
263
|
+
|
|
264
|
+
expect(result.accessToken).toBe(token);
|
|
265
|
+
expect(result.expiresIn).toBe(0); // GitHub tokens don't expire
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('revokeAccessToken', () => {
|
|
270
|
+
it('should attempt to revoke token', async () => {
|
|
271
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
272
|
+
ok: true
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const result = await provider.revokeAccessToken('test-token');
|
|
276
|
+
|
|
277
|
+
expect(result).toBe(true);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should return false on revocation failure', async () => {
|
|
281
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
282
|
+
ok: false
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const result = await provider.revokeAccessToken('test-token');
|
|
286
|
+
|
|
287
|
+
expect(result).toBe(false);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { OAuthProviderConfig, OAuthUserProfile, OAuthTokenResponse } from '../oauth-types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GitHub OAuth Configuration
|
|
5
|
+
*/
|
|
6
|
+
export interface GitHubOAuthConfig extends OAuthProviderConfig {
|
|
7
|
+
scope?: string[];
|
|
8
|
+
allowSignup?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GitHub OAuth Provider
|
|
13
|
+
* Handles GitHub OAuth 2.0 flow
|
|
14
|
+
*/
|
|
15
|
+
export class GitHubOAuthProvider {
|
|
16
|
+
private clientId: string;
|
|
17
|
+
private clientSecret: string;
|
|
18
|
+
private redirectUri: string;
|
|
19
|
+
private scope: string[];
|
|
20
|
+
private allowSignup: boolean;
|
|
21
|
+
|
|
22
|
+
private readonly authorizationEndpoint = 'https://github.com/login/oauth/authorize';
|
|
23
|
+
private readonly tokenEndpoint = 'https://github.com/login/oauth/access_token';
|
|
24
|
+
private readonly userinfoEndpoint = 'https://api.github.com/user';
|
|
25
|
+
|
|
26
|
+
constructor(config: GitHubOAuthConfig) {
|
|
27
|
+
if (!config.clientId || !config.clientSecret || !config.redirectUri) {
|
|
28
|
+
throw new Error('GitHub OAuth configuration is missing required fields');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.clientId = config.clientId;
|
|
32
|
+
this.clientSecret = config.clientSecret;
|
|
33
|
+
this.redirectUri = config.redirectUri;
|
|
34
|
+
this.scope = config.scope || ['read:user', 'user:email'];
|
|
35
|
+
this.allowSignup = config.allowSignup !== false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Generate authorization URL
|
|
40
|
+
*/
|
|
41
|
+
generateAuthorizationUrl(state: string): string {
|
|
42
|
+
const params = new URLSearchParams({
|
|
43
|
+
client_id: this.clientId,
|
|
44
|
+
redirect_uri: this.redirectUri,
|
|
45
|
+
scope: this.scope.join(' '),
|
|
46
|
+
state,
|
|
47
|
+
allow_signup: this.allowSignup.toString()
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return `${this.authorizationEndpoint}?${params.toString()}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Exchange authorization code for tokens
|
|
55
|
+
*/
|
|
56
|
+
async exchangeCode(code: string): Promise<OAuthTokenResponse> {
|
|
57
|
+
try {
|
|
58
|
+
const response = await fetch(this.tokenEndpoint, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: {
|
|
61
|
+
'Content-Type': 'application/json',
|
|
62
|
+
Accept: 'application/json'
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify({
|
|
65
|
+
code,
|
|
66
|
+
client_id: this.clientId,
|
|
67
|
+
client_secret: this.clientSecret,
|
|
68
|
+
redirect_uri: this.redirectUri
|
|
69
|
+
})
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new Error(`GitHub token exchange failed: ${response.statusText}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const data = await response.json();
|
|
77
|
+
|
|
78
|
+
if (data.error) {
|
|
79
|
+
throw new Error(`GitHub OAuth error: ${data.error}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
accessToken: data.access_token,
|
|
84
|
+
expiresIn: data.expires_in || 28800, // 8 hours default
|
|
85
|
+
tokenType: data.token_type || 'Bearer'
|
|
86
|
+
};
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw new Error(`Failed to exchange GitHub authorization code: ${error}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Fetch user profile from GitHub
|
|
94
|
+
* Also fetches email if not in primary profile
|
|
95
|
+
*/
|
|
96
|
+
async fetchUserProfile(accessToken: string): Promise<OAuthUserProfile> {
|
|
97
|
+
try {
|
|
98
|
+
// Fetch user profile
|
|
99
|
+
const profileResponse = await fetch(this.userinfoEndpoint, {
|
|
100
|
+
headers: {
|
|
101
|
+
Authorization: `Bearer ${accessToken}`,
|
|
102
|
+
'User-Agent': 'svelte-auth-system'
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (!profileResponse.ok) {
|
|
107
|
+
throw new Error(`Failed to fetch GitHub user info: ${profileResponse.statusText}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const profileData = await profileResponse.json();
|
|
111
|
+
|
|
112
|
+
// If email is not public, fetch from emails endpoint
|
|
113
|
+
let email = profileData.email;
|
|
114
|
+
if (!email) {
|
|
115
|
+
email = await this.fetchUserEmail(accessToken);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!email) {
|
|
119
|
+
throw new Error('Could not retrieve email from GitHub');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
id: profileData.id.toString(),
|
|
124
|
+
email,
|
|
125
|
+
name: profileData.name,
|
|
126
|
+
avatar: profileData.avatar_url,
|
|
127
|
+
provider: 'github'
|
|
128
|
+
};
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw new Error(`Failed to fetch GitHub user profile: ${error}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Fetch user email from GitHub emails endpoint
|
|
136
|
+
*/
|
|
137
|
+
private async fetchUserEmail(accessToken: string): Promise<string | null> {
|
|
138
|
+
try {
|
|
139
|
+
const response = await fetch('https://api.github.com/user/emails', {
|
|
140
|
+
headers: {
|
|
141
|
+
Authorization: `Bearer ${accessToken}`,
|
|
142
|
+
'User-Agent': 'svelte-auth-system'
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const emails = await response.json();
|
|
151
|
+
|
|
152
|
+
// Find primary email
|
|
153
|
+
const primaryEmail = emails.find((e: any) => e.primary);
|
|
154
|
+
if (primaryEmail) {
|
|
155
|
+
return primaryEmail.email;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Find verified email
|
|
159
|
+
const verifiedEmail = emails.find((e: any) => e.verified);
|
|
160
|
+
if (verifiedEmail) {
|
|
161
|
+
return verifiedEmail.email;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Return first email if available
|
|
165
|
+
return emails[0]?.email || null;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.error('Failed to fetch GitHub user email:', error);
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* GitHub does not support refresh tokens (token doesn't expire)
|
|
174
|
+
* This method is a no-op but included for API consistency
|
|
175
|
+
*/
|
|
176
|
+
async refreshAccessToken(accessToken: string): Promise<OAuthTokenResponse> {
|
|
177
|
+
// GitHub tokens don't expire, so we just return the same token
|
|
178
|
+
return {
|
|
179
|
+
accessToken,
|
|
180
|
+
expiresIn: 0,
|
|
181
|
+
tokenType: 'Bearer'
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Validate authorization code format
|
|
187
|
+
*/
|
|
188
|
+
validateAuthorizationCode(code: string): boolean {
|
|
189
|
+
return !!(code && typeof code === 'string' && code.length > 0);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Validate access token format
|
|
194
|
+
*/
|
|
195
|
+
validateAccessToken(token: string): boolean {
|
|
196
|
+
return !!(token && typeof token === 'string' && token.length > 0);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Revoke access token
|
|
201
|
+
*/
|
|
202
|
+
async revokeAccessToken(accessToken: string): Promise<boolean> {
|
|
203
|
+
try {
|
|
204
|
+
// GitHub requires basic auth with clientId:clientSecret
|
|
205
|
+
const auth = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
|
|
206
|
+
|
|
207
|
+
const response = await fetch(
|
|
208
|
+
`https://api.github.com/applications/${this.clientId}/token`,
|
|
209
|
+
{
|
|
210
|
+
method: 'DELETE',
|
|
211
|
+
headers: {
|
|
212
|
+
Authorization: `Basic ${auth}`,
|
|
213
|
+
'User-Agent': 'svelte-auth-system',
|
|
214
|
+
'Content-Type': 'application/json'
|
|
215
|
+
},
|
|
216
|
+
body: JSON.stringify({ access_token: accessToken })
|
|
217
|
+
}
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return response.ok;
|
|
221
|
+
} catch (error) {
|
|
222
|
+
console.error('Failed to revoke GitHub access token:', error);
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|