@quatrain/auth-github 1.2.10

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/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # @quatrain/auth-github
2
+
3
+ Authentication adapter and pluggable endpoints for GitHub OAuth 2.0 Web Application Flow.
4
+
5
+ ## Installation
6
+
7
+ This package is a workspace package within the Quatrain Core monorepo. It depends on `@quatrain/auth` and `@quatrain/api`.
8
+
9
+ ```bash
10
+ yarn add @quatrain/auth-github
11
+ ```
12
+
13
+ ## Features
14
+
15
+ - **OAuth 2.0 Flow**: Handles authorization URL generation and exchanging code for token.
16
+ - **Pluggable API Router**: Framework-agnostic `GithubAuthApi` endpoint handler matching `ServerAdapter` specification.
17
+ - **Deep Linking**: Supports generic redirect schemes for mobile contexts.
18
+ - **GitHub API Utilities**: Methods to check repository existence and create new repositories.
@@ -0,0 +1,34 @@
1
+ import { AbstractOAuthAdapter } from '@quatrain/auth';
2
+ /**
3
+ * Authentication adapter for GitHub OAuth 2.0 Web Application Flow.
4
+ * Handles profiles, repository existence checks, and repository creation.
5
+ */
6
+ export declare class GithubAuthAdapter extends AbstractOAuthAdapter {
7
+ protected _authorizationEndpoint: string;
8
+ protected _tokenEndpoint: string;
9
+ protected _userProfileEndpoint: string;
10
+ /**
11
+ * Factory method to create a new instance from config credentials.
12
+ */
13
+ static factory(config: any): GithubAuthAdapter | null;
14
+ /**
15
+ * Returns the pluggable API routes builder.
16
+ */
17
+ getEndpointHandler(): any;
18
+ /**
19
+ * Returns user details from GitHub by validating the access token.
20
+ */
21
+ getAuthToken(accessToken: string): Promise<any>;
22
+ /**
23
+ * Custom GitHub API call to check repository existence.
24
+ */
25
+ checkRepositoryExists(accessToken: string, owner: string, name: string): Promise<boolean>;
26
+ /**
27
+ * Custom GitHub API call to create a repository.
28
+ */
29
+ createRepository(accessToken: string, name: string, options?: {
30
+ private?: boolean;
31
+ description?: string;
32
+ autoInit?: boolean;
33
+ }): Promise<any>;
34
+ }
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
36
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
37
+ return new (P || (P = Promise))(function (resolve, reject) {
38
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
39
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
40
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
41
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
42
+ });
43
+ };
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.GithubAuthAdapter = void 0;
46
+ const auth_1 = require("@quatrain/auth");
47
+ const GithubAuthApi_1 = require("./GithubAuthApi");
48
+ const nativeFetch = __importStar(require("node-fetch-native"));
49
+ /**
50
+ * Authentication adapter for GitHub OAuth 2.0 Web Application Flow.
51
+ * Handles profiles, repository existence checks, and repository creation.
52
+ */
53
+ class GithubAuthAdapter extends auth_1.AbstractOAuthAdapter {
54
+ constructor() {
55
+ super(...arguments);
56
+ this._authorizationEndpoint = 'https://github.com/login/oauth/authorize';
57
+ this._tokenEndpoint = 'https://github.com/login/oauth/access_token';
58
+ this._userProfileEndpoint = 'https://api.github.com/user';
59
+ }
60
+ /**
61
+ * Factory method to create a new instance from config credentials.
62
+ */
63
+ static factory(config) {
64
+ if (!config.clientId || !config.clientSecret)
65
+ return null;
66
+ return new GithubAuthAdapter({ config });
67
+ }
68
+ /**
69
+ * Returns the pluggable API routes builder.
70
+ */
71
+ getEndpointHandler() {
72
+ return GithubAuthApi_1.GithubAuthApi;
73
+ }
74
+ /**
75
+ * Returns user details from GitHub by validating the access token.
76
+ */
77
+ getAuthToken(accessToken) {
78
+ return __awaiter(this, void 0, void 0, function* () {
79
+ const response = yield nativeFetch.fetch(this._userProfileEndpoint, {
80
+ headers: {
81
+ 'Authorization': `Bearer ${accessToken}`,
82
+ 'Accept': 'application/json',
83
+ 'User-Agent': 'Quatrain-Auth-Github'
84
+ }
85
+ });
86
+ if (!response.ok) {
87
+ throw new Error(`Failed to fetch Github user profile: ${response.statusText}`);
88
+ }
89
+ return yield response.json();
90
+ });
91
+ }
92
+ /**
93
+ * Custom GitHub API call to check repository existence.
94
+ */
95
+ checkRepositoryExists(accessToken, owner, name) {
96
+ return __awaiter(this, void 0, void 0, function* () {
97
+ const response = yield nativeFetch.fetch(`https://api.github.com/repos/${owner}/${name}`, {
98
+ headers: {
99
+ 'Authorization': `Bearer ${accessToken}`,
100
+ 'Accept': 'application/json',
101
+ 'User-Agent': 'Quatrain-Auth-Github'
102
+ }
103
+ });
104
+ return response.status === 200;
105
+ });
106
+ }
107
+ /**
108
+ * Custom GitHub API call to create a repository.
109
+ */
110
+ createRepository(accessToken_1, name_1) {
111
+ return __awaiter(this, arguments, void 0, function* (accessToken, name, options = {}) {
112
+ const response = yield nativeFetch.fetch('https://api.github.com/user/repos', {
113
+ method: 'POST',
114
+ headers: {
115
+ 'Authorization': `Bearer ${accessToken}`,
116
+ 'Content-Type': 'application/json',
117
+ 'Accept': 'application/json',
118
+ 'User-Agent': 'Quatrain-Auth-Github'
119
+ },
120
+ body: JSON.stringify({
121
+ name,
122
+ private: options.private !== false,
123
+ description: options.description || 'Dépôt créé automatiquement par Modaka',
124
+ auto_init: options.autoInit !== false
125
+ })
126
+ });
127
+ if (!response.ok) {
128
+ const errData = yield response.json().catch(() => ({}));
129
+ throw new Error(`Failed to create repository: ${errData.message || response.statusText}`);
130
+ }
131
+ return yield response.json();
132
+ });
133
+ }
134
+ }
135
+ exports.GithubAuthAdapter = GithubAuthAdapter;
136
+ //# sourceMappingURL=GithubAuthAdapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GithubAuthAdapter.js","sourceRoot":"","sources":["../src/GithubAuthAdapter.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,yCAAqD;AACrD,mDAA+C;AAC/C,+DAAgD;AAEhD;;;GAGG;AACH,MAAa,iBAAkB,SAAQ,2BAAoB;IAA3D;;QACa,2BAAsB,GAAG,0CAA0C,CAAA;QACnE,mBAAc,GAAG,6CAA6C,CAAA;QAC9D,yBAAoB,GAAG,6BAA6B,CAAA;IA6EjE,CAAC;IA3EE;;OAEG;IACH,MAAM,CAAC,OAAO,CAAC,MAAW;QACvB,IAAI,CAAC,MAAM,CAAC,QAAQ,IAAI,CAAC,MAAM,CAAC,YAAY;YAAE,OAAO,IAAI,CAAA;QACzD,OAAO,IAAI,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAC,CAAA;IAC3C,CAAC;IAED;;OAEG;IACa,kBAAkB;QAC/B,OAAO,6BAAa,CAAA;IACvB,CAAC;IAED;;OAEG;IACG,YAAY,CAAC,WAAmB;;YACnC,MAAM,QAAQ,GAAG,MAAM,WAAW,CAAC,KAAK,CAAC,IAAI,CAAC,oBAAoB,EAAE;gBACjE,OAAO,EAAE;oBACN,eAAe,EAAE,UAAU,WAAW,EAAE;oBACxC,QAAQ,EAAE,kBAAkB;oBAC5B,YAAY,EAAE,sBAAsB;iBACtC;aACH,CAAC,CAAA;YAEF,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;gBAChB,MAAM,IAAI,KAAK,CAAC,wCAAwC,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAA;YACjF,CAAC;YAED,OAAO,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAA;QAC/B,CAAC;KAAA;IAED;;OAEG;IACG,qBAAqB,CAAC,WAAmB,EAAE,KAAa,EAAE,IAAY;;YACzE,MAAM,QAAQ,GAAG,MAAM,WAAW,CAAC,KAAK,CAAC,gCAAgC,KAAK,IAAI,IAAI,EAAE,EAAE;gBACvF,OAAO,EAAE;oBACN,eAAe,EAAE,UAAU,WAAW,EAAE;oBACxC,QAAQ,EAAE,kBAAkB;oBAC5B,YAAY,EAAE,sBAAsB;iBACtC;aACH,CAAC,CAAA;YACF,OAAO,QAAQ,CAAC,MAAM,KAAK,GAAG,CAAA;QACjC,CAAC;KAAA;IAED;;OAEG;IACG,gBAAgB;6DAAC,WAAmB,EAAE,IAAY,EAAE,UAA2E,EAAE;YACpI,MAAM,QAAQ,GAAG,MAAM,WAAW,CAAC,KAAK,CAAC,mCAAmC,EAAE;gBAC3E,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE;oBACN,eAAe,EAAE,UAAU,WAAW,EAAE;oBACxC,cAAc,EAAE,kBAAkB;oBAClC,QAAQ,EAAE,kBAAkB;oBAC5B,YAAY,EAAE,sBAAsB;iBACtC;gBACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;oBAClB,IAAI;oBACJ,OAAO,EAAE,OAAO,CAAC,OAAO,KAAK,KAAK;oBAClC,WAAW,EAAE,OAAO,CAAC,WAAW,IAAI,uCAAuC;oBAC3E,SAAS,EAAE,OAAO,CAAC,QAAQ,KAAK,KAAK;iBACvC,CAAC;aACJ,CAAC,CAAA;YAEF,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;gBAChB,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;gBACvD,MAAM,IAAI,KAAK,CAAC,gCAAgC,OAAO,CAAC,OAAO,IAAI,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAA;YAC5F,CAAC;YAED,OAAO,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAA;QAC/B,CAAC;KAAA;CACH;AAhFD,8CAgFC"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
36
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
37
+ return new (P || (P = Promise))(function (resolve, reject) {
38
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
39
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
40
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
41
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
42
+ });
43
+ };
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ const GithubAuthAdapter_1 = require("./GithubAuthAdapter");
46
+ const nativeFetch = __importStar(require("node-fetch-native"));
47
+ jest.mock('node-fetch-native');
48
+ describe('GithubAuthAdapter', () => {
49
+ let adapter;
50
+ beforeEach(() => {
51
+ jest.clearAllMocks();
52
+ adapter = new GithubAuthAdapter_1.GithubAuthAdapter({
53
+ config: {
54
+ clientId: 'test-client-id',
55
+ clientSecret: 'test-client-secret',
56
+ },
57
+ });
58
+ });
59
+ describe('factory()', () => {
60
+ it('should return null if clientId or clientSecret is missing', () => {
61
+ expect(GithubAuthAdapter_1.GithubAuthAdapter.factory({})).toBeNull();
62
+ expect(GithubAuthAdapter_1.GithubAuthAdapter.factory({ clientId: 'id' })).toBeNull();
63
+ expect(GithubAuthAdapter_1.GithubAuthAdapter.factory({ clientSecret: 'secret' })).toBeNull();
64
+ });
65
+ it('should return an instance if config is valid', () => {
66
+ const instance = GithubAuthAdapter_1.GithubAuthAdapter.factory({
67
+ clientId: 'id',
68
+ clientSecret: 'secret',
69
+ });
70
+ expect(instance).toBeInstanceOf(GithubAuthAdapter_1.GithubAuthAdapter);
71
+ });
72
+ });
73
+ describe('getAuthorizationUrl()', () => {
74
+ it('should return correct authorization URL', () => {
75
+ const url = adapter.getAuthorizationUrl('http://callback', ['repo', 'user'], 'state-123');
76
+ expect(url).toBe('https://github.com/login/oauth/authorize?client_id=test-client-id&scope=repo%20user&redirect_uri=http%3A%2F%2Fcallback&state=state-123');
77
+ });
78
+ });
79
+ describe('exchangeCodeForToken()', () => {
80
+ it('should exchange code for token successfully', () => __awaiter(void 0, void 0, void 0, function* () {
81
+ const mockResponse = {
82
+ ok: true,
83
+ json: jest.fn().mockResolvedValue({
84
+ access_token: 'github-access-token-123',
85
+ token_type: 'bearer',
86
+ scope: 'repo',
87
+ }),
88
+ };
89
+ nativeFetch.fetch.mockResolvedValue(mockResponse);
90
+ const result = yield adapter.exchangeCodeForToken('code-123', 'http://callback');
91
+ expect(result.access_token).toBe('github-access-token-123');
92
+ expect(nativeFetch.fetch).toHaveBeenCalledWith('https://github.com/login/oauth/access_token', expect.objectContaining({
93
+ method: 'POST',
94
+ body: JSON.stringify({
95
+ client_id: 'test-client-id',
96
+ client_secret: 'test-client-secret',
97
+ code: 'code-123',
98
+ grant_type: 'authorization_code',
99
+ redirect_uri: 'http://callback',
100
+ }),
101
+ }));
102
+ }));
103
+ it('should throw error on failed exchange request', () => __awaiter(void 0, void 0, void 0, function* () {
104
+ const mockResponse = {
105
+ ok: false,
106
+ statusText: 'Bad Request',
107
+ };
108
+ nativeFetch.fetch.mockResolvedValue(mockResponse);
109
+ yield expect(adapter.exchangeCodeForToken('code-123')).rejects.toThrow('OAuth token exchange failed: Bad Request');
110
+ }));
111
+ });
112
+ describe('getAuthToken()', () => {
113
+ it('should fetch and return user profile information', () => __awaiter(void 0, void 0, void 0, function* () {
114
+ const mockResponse = {
115
+ ok: true,
116
+ json: jest.fn().mockResolvedValue({
117
+ id: 9999,
118
+ login: 'octocat',
119
+ name: 'The Octocat',
120
+ }),
121
+ };
122
+ nativeFetch.fetch.mockResolvedValue(mockResponse);
123
+ const result = yield adapter.getAuthToken('token-123');
124
+ expect(result.login).toBe('octocat');
125
+ expect(nativeFetch.fetch).toHaveBeenCalledWith('https://api.github.com/user', expect.objectContaining({
126
+ headers: {
127
+ 'Authorization': 'Bearer token-123',
128
+ 'Accept': 'application/json',
129
+ 'User-Agent': 'Quatrain-Auth-Github',
130
+ },
131
+ }));
132
+ }));
133
+ });
134
+ describe('checkRepositoryExists()', () => {
135
+ it('should return true if repo exists', () => __awaiter(void 0, void 0, void 0, function* () {
136
+ const mockResponse = { status: 200 };
137
+ nativeFetch.fetch.mockResolvedValue(mockResponse);
138
+ const exists = yield adapter.checkRepositoryExists('token-123', 'owner', 'repo');
139
+ expect(exists).toBe(true);
140
+ expect(nativeFetch.fetch).toHaveBeenCalledWith('https://api.github.com/repos/owner/repo', expect.any(Object));
141
+ }));
142
+ it('should return false if repo does not exist', () => __awaiter(void 0, void 0, void 0, function* () {
143
+ const mockResponse = { status: 404 };
144
+ nativeFetch.fetch.mockResolvedValue(mockResponse);
145
+ const exists = yield adapter.checkRepositoryExists('token-123', 'owner', 'repo');
146
+ expect(exists).toBe(false);
147
+ }));
148
+ });
149
+ describe('createRepository()', () => {
150
+ it('should call GitHub user repos endpoint to create a repo', () => __awaiter(void 0, void 0, void 0, function* () {
151
+ const mockResponse = {
152
+ ok: true,
153
+ json: jest.fn().mockResolvedValue({
154
+ id: 123456,
155
+ name: 'new-repo',
156
+ full_name: 'octocat/new-repo',
157
+ }),
158
+ };
159
+ nativeFetch.fetch.mockResolvedValue(mockResponse);
160
+ const result = yield adapter.createRepository('token-123', 'new-repo', {
161
+ private: true,
162
+ description: 'My new repo',
163
+ });
164
+ expect(result.name).toBe('new-repo');
165
+ expect(nativeFetch.fetch).toHaveBeenCalledWith('https://api.github.com/user/repos', expect.objectContaining({
166
+ method: 'POST',
167
+ body: JSON.stringify({
168
+ name: 'new-repo',
169
+ private: true,
170
+ description: 'My new repo',
171
+ auto_init: true,
172
+ }),
173
+ }));
174
+ }));
175
+ });
176
+ });
177
+ //# sourceMappingURL=GithubAuthAdapter.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GithubAuthAdapter.test.js","sourceRoot":"","sources":["../src/GithubAuthAdapter.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ import { ServerAdapter } from '@quatrain/api';
2
+ /**
3
+ * Pluggable router endpoints for Github OAuth authentication.
4
+ * Compatible with any Quatrain ServerAdapter (Astro, Express, etc.).
5
+ */
6
+ export declare function GithubAuthApi(router: ServerAdapter, path: string, options?: any): void;
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.GithubAuthApi = GithubAuthApi;
13
+ const GithubAuthAdapter_1 = require("./GithubAuthAdapter");
14
+ /**
15
+ * Pluggable router endpoints for Github OAuth authentication.
16
+ * Compatible with any Quatrain ServerAdapter (Astro, Express, etc.).
17
+ */
18
+ function GithubAuthApi(router, path, options = {}) {
19
+ const adapter = options.adapter || GithubAuthAdapter_1.GithubAuthAdapter.factory(options.config || {});
20
+ if (!adapter) {
21
+ throw new Error('GithubAuthApi requires a valid GithubAuthAdapter or configuration.');
22
+ }
23
+ // Route: GET [root]/login
24
+ router.get('/login', (req, res) => __awaiter(this, void 0, void 0, function* () {
25
+ var _a, _b;
26
+ const redirectUri = req.query.redirect_uri;
27
+ const scopes = (req.query.scopes || 'repo').split(',');
28
+ const state = req.query.state;
29
+ const authUrl = adapter.getAuthorizationUrl(redirectUri, scopes, state);
30
+ const wantsJson = req.query.json === 'true' || ((_b = (_a = req.headers) === null || _a === void 0 ? void 0 : _a.accept) === null || _b === void 0 ? void 0 : _b.includes('application/json'));
31
+ if (wantsJson) {
32
+ res.status(200).json({ url: authUrl });
33
+ return;
34
+ }
35
+ res.status(302).setHeader('Location', authUrl);
36
+ res.send(`Redirecting to GitHub...`);
37
+ }));
38
+ // Route: GET [root]/callback
39
+ router.get('/callback', (req, res) => __awaiter(this, void 0, void 0, function* () {
40
+ const code = req.query.code;
41
+ const redirectUri = req.query.redirect_uri;
42
+ const platform = req.query.platform;
43
+ if (!code) {
44
+ res.status(400).send('Missing authorization code.');
45
+ return;
46
+ }
47
+ try {
48
+ const tokenData = yield adapter.exchangeCodeForToken(code, redirectUri);
49
+ // Support standard deep link redirect for mobile context if an app scheme is configured
50
+ const appScheme = req.query.app_scheme || options.appScheme;
51
+ if (appScheme) {
52
+ const redirectUrl = `${appScheme}://auth/github/callback?token=${tokenData.access_token}`;
53
+ res.status(302).setHeader('Location', redirectUrl);
54
+ res.send(`Redirecting back to app...`);
55
+ return;
56
+ }
57
+ // Support standard redirect for web context if a target URI is configured
58
+ const webRedirectUri = req.query.web_redirect_uri || options.webRedirectUri;
59
+ if (webRedirectUri) {
60
+ const separator = webRedirectUri.includes('?') ? '&' : '?';
61
+ const redirectUrl = `${webRedirectUri}${separator}token=${tokenData.access_token}`;
62
+ res.status(302).setHeader('Location', redirectUrl);
63
+ res.send(`Redirecting...`);
64
+ return;
65
+ }
66
+ res.status(200).json(tokenData);
67
+ }
68
+ catch (err) {
69
+ res.status(500).send(`OAuth error: ${err.message}`);
70
+ }
71
+ }));
72
+ }
73
+ //# sourceMappingURL=GithubAuthApi.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GithubAuthApi.js","sourceRoot":"","sources":["../src/GithubAuthApi.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ const GithubAuthApi_1 = require("./GithubAuthApi");
13
+ describe('GithubAuthApi', () => {
14
+ let mockRouter;
15
+ let mockAdapter;
16
+ let routes = {};
17
+ beforeEach(() => {
18
+ routes = {};
19
+ mockRouter = {
20
+ get: jest.fn().mockImplementation((path, handler) => {
21
+ routes[path] = handler;
22
+ }),
23
+ };
24
+ mockAdapter = {
25
+ getAuthorizationUrl: jest.fn().mockReturnValue('https://github.com/login/authorize'),
26
+ exchangeCodeForToken: jest.fn().mockResolvedValue({
27
+ access_token: 'token-abc',
28
+ }),
29
+ };
30
+ });
31
+ it('should register /login and /callback routes', () => {
32
+ (0, GithubAuthApi_1.GithubAuthApi)(mockRouter, '/api/auth/github', {
33
+ adapter: mockAdapter,
34
+ });
35
+ expect(mockRouter.get).toHaveBeenCalledWith('/login', expect.any(Function));
36
+ expect(mockRouter.get).toHaveBeenCalledWith('/callback', expect.any(Function));
37
+ });
38
+ it('should handle /login route redirect', () => __awaiter(void 0, void 0, void 0, function* () {
39
+ (0, GithubAuthApi_1.GithubAuthApi)(mockRouter, '/api/auth/github', {
40
+ adapter: mockAdapter,
41
+ });
42
+ const req = {
43
+ query: {
44
+ redirect_uri: 'http://my-callback',
45
+ scopes: 'repo,user',
46
+ state: 'mystate',
47
+ },
48
+ };
49
+ const res = {
50
+ status: jest.fn().mockReturnThis(),
51
+ setHeader: jest.fn().mockReturnThis(),
52
+ send: jest.fn(),
53
+ };
54
+ yield routes['/login'](req, res);
55
+ expect(mockAdapter.getAuthorizationUrl).toHaveBeenCalledWith('http://my-callback', ['repo', 'user'], 'mystate');
56
+ expect(res.status).toHaveBeenCalledWith(302);
57
+ expect(res.setHeader).toHaveBeenCalledWith('Location', 'https://github.com/login/authorize');
58
+ }));
59
+ it('should handle /callback route token exchange', () => __awaiter(void 0, void 0, void 0, function* () {
60
+ (0, GithubAuthApi_1.GithubAuthApi)(mockRouter, '/api/auth/github', {
61
+ adapter: mockAdapter,
62
+ });
63
+ const req = {
64
+ query: {
65
+ code: 'code-123',
66
+ redirect_uri: 'http://my-callback',
67
+ },
68
+ };
69
+ const res = {
70
+ status: jest.fn().mockReturnThis(),
71
+ json: jest.fn(),
72
+ };
73
+ yield routes['/callback'](req, res);
74
+ expect(mockAdapter.exchangeCodeForToken).toHaveBeenCalledWith('code-123', 'http://my-callback');
75
+ expect(res.status).toHaveBeenCalledWith(200);
76
+ expect(res.json).toHaveBeenCalledWith({ access_token: 'token-abc' });
77
+ }));
78
+ it('should redirect to deep link for mobile platform', () => __awaiter(void 0, void 0, void 0, function* () {
79
+ (0, GithubAuthApi_1.GithubAuthApi)(mockRouter, '/api/auth/github', {
80
+ adapter: mockAdapter,
81
+ appScheme: 'myapp',
82
+ });
83
+ const req = {
84
+ query: {
85
+ code: 'code-123',
86
+ },
87
+ };
88
+ const res = {
89
+ status: jest.fn().mockReturnThis(),
90
+ setHeader: jest.fn().mockReturnThis(),
91
+ send: jest.fn(),
92
+ };
93
+ yield routes['/callback'](req, res);
94
+ expect(res.status).toHaveBeenCalledWith(302);
95
+ expect(res.setHeader).toHaveBeenCalledWith('Location', 'myapp://auth/github/callback?token=token-abc');
96
+ }));
97
+ it('should redirect to web URL if webRedirectUri is configured', () => __awaiter(void 0, void 0, void 0, function* () {
98
+ (0, GithubAuthApi_1.GithubAuthApi)(mockRouter, '/api/auth/github', {
99
+ adapter: mockAdapter,
100
+ webRedirectUri: 'http://localhost:3000/settings?tab=sync',
101
+ });
102
+ const req = {
103
+ query: {
104
+ code: 'code-123',
105
+ },
106
+ };
107
+ const res = {
108
+ status: jest.fn().mockReturnThis(),
109
+ setHeader: jest.fn().mockReturnThis(),
110
+ send: jest.fn(),
111
+ };
112
+ yield routes['/callback'](req, res);
113
+ expect(res.status).toHaveBeenCalledWith(302);
114
+ expect(res.setHeader).toHaveBeenCalledWith('Location', 'http://localhost:3000/settings?tab=sync&token=token-abc');
115
+ }));
116
+ });
117
+ //# sourceMappingURL=GithubAuthApi.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GithubAuthApi.test.js","sourceRoot":"","sources":["../src/GithubAuthApi.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export * from './GithubAuthAdapter';
2
+ export * from './GithubAuthApi';
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./GithubAuthAdapter"), exports);
18
+ __exportStar(require("./GithubAuthApi"), exports);
19
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,sDAAmC;AACnC,kDAA+B"}
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@quatrain/auth-github",
3
+ "version": "1.2.10",
4
+ "license": "AGPL-3.0-only",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "bun": "src/index.ts",
8
+ "files": [
9
+ "LICENSE.md",
10
+ "src/",
11
+ "dist/",
12
+ "README.md"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/Quatrain/Core.git",
17
+ "directory": "packages/auth-github"
18
+ },
19
+ "author": "Quatrain Développement SAS <developers@quatrain.com>",
20
+ "dependencies": {
21
+ "@quatrain/api": "^1.1.7",
22
+ "@quatrain/auth": "^1.2.10",
23
+ "@quatrain/backend": "^1.2.17",
24
+ "node-fetch-native": "^1.6.4"
25
+ },
26
+ "devDependencies": {
27
+ "@tsconfig/recommended": "^1.0.1",
28
+ "@types/node": "^22.10.1",
29
+ "typescript": "^5.2.2"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc",
33
+ "wbuild": "tsc --watch",
34
+ "bump-to": "yarn version"
35
+ }
36
+ }
@@ -0,0 +1,172 @@
1
+ import { GithubAuthAdapter } from './GithubAuthAdapter'
2
+ import * as nativeFetch from 'node-fetch-native'
3
+
4
+ jest.mock('node-fetch-native')
5
+
6
+ describe('GithubAuthAdapter', () => {
7
+ let adapter: GithubAuthAdapter
8
+
9
+ beforeEach(() => {
10
+ jest.clearAllMocks()
11
+
12
+ adapter = new GithubAuthAdapter({
13
+ config: {
14
+ clientId: 'test-client-id',
15
+ clientSecret: 'test-client-secret',
16
+ },
17
+ })
18
+ })
19
+
20
+ describe('factory()', () => {
21
+ it('should return null if clientId or clientSecret is missing', () => {
22
+ expect(GithubAuthAdapter.factory({})).toBeNull()
23
+ expect(GithubAuthAdapter.factory({ clientId: 'id' })).toBeNull()
24
+ expect(GithubAuthAdapter.factory({ clientSecret: 'secret' })).toBeNull()
25
+ })
26
+
27
+ it('should return an instance if config is valid', () => {
28
+ const instance = GithubAuthAdapter.factory({
29
+ clientId: 'id',
30
+ clientSecret: 'secret',
31
+ })
32
+ expect(instance).toBeInstanceOf(GithubAuthAdapter)
33
+ })
34
+ })
35
+
36
+ describe('getAuthorizationUrl()', () => {
37
+ it('should return correct authorization URL', () => {
38
+ const url = adapter.getAuthorizationUrl(
39
+ 'http://callback',
40
+ ['repo', 'user'],
41
+ 'state-123'
42
+ )
43
+ expect(url).toBe(
44
+ 'https://github.com/login/oauth/authorize?client_id=test-client-id&scope=repo%20user&redirect_uri=http%3A%2F%2Fcallback&state=state-123'
45
+ )
46
+ })
47
+ })
48
+
49
+ describe('exchangeCodeForToken()', () => {
50
+ it('should exchange code for token successfully', async () => {
51
+ const mockResponse = {
52
+ ok: true,
53
+ json: jest.fn().mockResolvedValue({
54
+ access_token: 'github-access-token-123',
55
+ token_type: 'bearer',
56
+ scope: 'repo',
57
+ }),
58
+ }
59
+ ;(nativeFetch.fetch as jest.Mock).mockResolvedValue(mockResponse)
60
+
61
+ const result = await adapter.exchangeCodeForToken('code-123', 'http://callback')
62
+ expect(result.access_token).toBe('github-access-token-123')
63
+ expect(nativeFetch.fetch).toHaveBeenCalledWith(
64
+ 'https://github.com/login/oauth/access_token',
65
+ expect.objectContaining({
66
+ method: 'POST',
67
+ body: JSON.stringify({
68
+ client_id: 'test-client-id',
69
+ client_secret: 'test-client-secret',
70
+ code: 'code-123',
71
+ grant_type: 'authorization_code',
72
+ redirect_uri: 'http://callback',
73
+ }),
74
+ })
75
+ )
76
+ })
77
+
78
+ it('should throw error on failed exchange request', async () => {
79
+ const mockResponse = {
80
+ ok: false,
81
+ statusText: 'Bad Request',
82
+ }
83
+ ;(nativeFetch.fetch as jest.Mock).mockResolvedValue(mockResponse)
84
+
85
+ await expect(
86
+ adapter.exchangeCodeForToken('code-123')
87
+ ).rejects.toThrow('OAuth token exchange failed: Bad Request')
88
+ })
89
+ })
90
+
91
+ describe('getAuthToken()', () => {
92
+ it('should fetch and return user profile information', async () => {
93
+ const mockResponse = {
94
+ ok: true,
95
+ json: jest.fn().mockResolvedValue({
96
+ id: 9999,
97
+ login: 'octocat',
98
+ name: 'The Octocat',
99
+ }),
100
+ }
101
+ ;(nativeFetch.fetch as jest.Mock).mockResolvedValue(mockResponse)
102
+
103
+ const result = await adapter.getAuthToken('token-123')
104
+ expect(result.login).toBe('octocat')
105
+ expect(nativeFetch.fetch).toHaveBeenCalledWith(
106
+ 'https://api.github.com/user',
107
+ expect.objectContaining({
108
+ headers: {
109
+ 'Authorization': 'Bearer token-123',
110
+ 'Accept': 'application/json',
111
+ 'User-Agent': 'Quatrain-Auth-Github',
112
+ },
113
+ })
114
+ )
115
+ })
116
+ })
117
+
118
+ describe('checkRepositoryExists()', () => {
119
+ it('should return true if repo exists', async () => {
120
+ const mockResponse = { status: 200 }
121
+ ;(nativeFetch.fetch as jest.Mock).mockResolvedValue(mockResponse)
122
+
123
+ const exists = await adapter.checkRepositoryExists('token-123', 'owner', 'repo')
124
+ expect(exists).toBe(true)
125
+ expect(nativeFetch.fetch).toHaveBeenCalledWith(
126
+ 'https://api.github.com/repos/owner/repo',
127
+ expect.any(Object)
128
+ )
129
+ })
130
+
131
+ it('should return false if repo does not exist', async () => {
132
+ const mockResponse = { status: 404 }
133
+ ;(nativeFetch.fetch as jest.Mock).mockResolvedValue(mockResponse)
134
+
135
+ const exists = await adapter.checkRepositoryExists('token-123', 'owner', 'repo')
136
+ expect(exists).toBe(false)
137
+ })
138
+ })
139
+
140
+ describe('createRepository()', () => {
141
+ it('should call GitHub user repos endpoint to create a repo', async () => {
142
+ const mockResponse = {
143
+ ok: true,
144
+ json: jest.fn().mockResolvedValue({
145
+ id: 123456,
146
+ name: 'new-repo',
147
+ full_name: 'octocat/new-repo',
148
+ }),
149
+ }
150
+ ;(nativeFetch.fetch as jest.Mock).mockResolvedValue(mockResponse)
151
+
152
+ const result = await adapter.createRepository('token-123', 'new-repo', {
153
+ private: true,
154
+ description: 'My new repo',
155
+ })
156
+
157
+ expect(result.name).toBe('new-repo')
158
+ expect(nativeFetch.fetch).toHaveBeenCalledWith(
159
+ 'https://api.github.com/user/repos',
160
+ expect.objectContaining({
161
+ method: 'POST',
162
+ body: JSON.stringify({
163
+ name: 'new-repo',
164
+ private: true,
165
+ description: 'My new repo',
166
+ auto_init: true,
167
+ }),
168
+ })
169
+ )
170
+ })
171
+ })
172
+ })
@@ -0,0 +1,89 @@
1
+ import { AbstractOAuthAdapter } from '@quatrain/auth'
2
+ import { GithubAuthApi } from './GithubAuthApi'
3
+ import * as nativeFetch from 'node-fetch-native'
4
+
5
+ /**
6
+ * Authentication adapter for GitHub OAuth 2.0 Web Application Flow.
7
+ * Handles profiles, repository existence checks, and repository creation.
8
+ */
9
+ export class GithubAuthAdapter extends AbstractOAuthAdapter {
10
+ protected _authorizationEndpoint = 'https://github.com/login/oauth/authorize'
11
+ protected _tokenEndpoint = 'https://github.com/login/oauth/access_token'
12
+ protected _userProfileEndpoint = 'https://api.github.com/user'
13
+
14
+ /**
15
+ * Factory method to create a new instance from config credentials.
16
+ */
17
+ static factory(config: any): GithubAuthAdapter | null {
18
+ if (!config.clientId || !config.clientSecret) return null
19
+ return new GithubAuthAdapter({ config })
20
+ }
21
+
22
+ /**
23
+ * Returns the pluggable API routes builder.
24
+ */
25
+ public override getEndpointHandler(): any {
26
+ return GithubAuthApi
27
+ }
28
+
29
+ /**
30
+ * Returns user details from GitHub by validating the access token.
31
+ */
32
+ async getAuthToken(accessToken: string): Promise<any> {
33
+ const response = await nativeFetch.fetch(this._userProfileEndpoint, {
34
+ headers: {
35
+ 'Authorization': `Bearer ${accessToken}`,
36
+ 'Accept': 'application/json',
37
+ 'User-Agent': 'Quatrain-Auth-Github'
38
+ }
39
+ })
40
+
41
+ if (!response.ok) {
42
+ throw new Error(`Failed to fetch Github user profile: ${response.statusText}`)
43
+ }
44
+
45
+ return await response.json()
46
+ }
47
+
48
+ /**
49
+ * Custom GitHub API call to check repository existence.
50
+ */
51
+ async checkRepositoryExists(accessToken: string, owner: string, name: string): Promise<boolean> {
52
+ const response = await nativeFetch.fetch(`https://api.github.com/repos/${owner}/${name}`, {
53
+ headers: {
54
+ 'Authorization': `Bearer ${accessToken}`,
55
+ 'Accept': 'application/json',
56
+ 'User-Agent': 'Quatrain-Auth-Github'
57
+ }
58
+ })
59
+ return response.status === 200
60
+ }
61
+
62
+ /**
63
+ * Custom GitHub API call to create a repository.
64
+ */
65
+ async createRepository(accessToken: string, name: string, options: { private?: boolean; description?: string; autoInit?: boolean } = {}): Promise<any> {
66
+ const response = await nativeFetch.fetch('https://api.github.com/user/repos', {
67
+ method: 'POST',
68
+ headers: {
69
+ 'Authorization': `Bearer ${accessToken}`,
70
+ 'Content-Type': 'application/json',
71
+ 'Accept': 'application/json',
72
+ 'User-Agent': 'Quatrain-Auth-Github'
73
+ },
74
+ body: JSON.stringify({
75
+ name,
76
+ private: options.private !== false,
77
+ description: options.description || 'Dépôt créé automatiquement par Modaka',
78
+ auto_init: options.autoInit !== false
79
+ })
80
+ })
81
+
82
+ if (!response.ok) {
83
+ const errData = await response.json().catch(() => ({}))
84
+ throw new Error(`Failed to create repository: ${errData.message || response.statusText}`)
85
+ }
86
+
87
+ return await response.json()
88
+ }
89
+ }
@@ -0,0 +1,142 @@
1
+ import { GithubAuthApi } from './GithubAuthApi'
2
+ import { ServerAdapter, ApiRequest, ApiResponse } from '@quatrain/api'
3
+ import { GithubAuthAdapter } from './GithubAuthAdapter'
4
+
5
+ describe('GithubAuthApi', () => {
6
+ let mockRouter: any
7
+ let mockAdapter: any
8
+ let routes: Record<string, Function> = {}
9
+
10
+ beforeEach(() => {
11
+ routes = {}
12
+ mockRouter = {
13
+ get: jest.fn().mockImplementation((path: string, handler: Function) => {
14
+ routes[path] = handler
15
+ }),
16
+ }
17
+
18
+ mockAdapter = {
19
+ getAuthorizationUrl: jest.fn().mockReturnValue('https://github.com/login/authorize'),
20
+ exchangeCodeForToken: jest.fn().mockResolvedValue({
21
+ access_token: 'token-abc',
22
+ }),
23
+ }
24
+ })
25
+
26
+ it('should register /login and /callback routes', () => {
27
+ GithubAuthApi(mockRouter as unknown as ServerAdapter, '/api/auth/github', {
28
+ adapter: mockAdapter,
29
+ })
30
+
31
+ expect(mockRouter.get).toHaveBeenCalledWith('/login', expect.any(Function))
32
+ expect(mockRouter.get).toHaveBeenCalledWith('/callback', expect.any(Function))
33
+ })
34
+
35
+ it('should handle /login route redirect', async () => {
36
+ GithubAuthApi(mockRouter as unknown as ServerAdapter, '/api/auth/github', {
37
+ adapter: mockAdapter,
38
+ })
39
+
40
+ const req = {
41
+ query: {
42
+ redirect_uri: 'http://my-callback',
43
+ scopes: 'repo,user',
44
+ state: 'mystate',
45
+ },
46
+ } as unknown as ApiRequest
47
+
48
+ const res = {
49
+ status: jest.fn().mockReturnThis(),
50
+ setHeader: jest.fn().mockReturnThis(),
51
+ send: jest.fn(),
52
+ } as unknown as ApiResponse
53
+
54
+ await routes['/login'](req, res)
55
+
56
+ expect(mockAdapter.getAuthorizationUrl).toHaveBeenCalledWith(
57
+ 'http://my-callback',
58
+ ['repo', 'user'],
59
+ 'mystate'
60
+ )
61
+ expect(res.status).toHaveBeenCalledWith(302)
62
+ expect(res.setHeader).toHaveBeenCalledWith('Location', 'https://github.com/login/authorize')
63
+ })
64
+
65
+ it('should handle /callback route token exchange', async () => {
66
+ GithubAuthApi(mockRouter as unknown as ServerAdapter, '/api/auth/github', {
67
+ adapter: mockAdapter,
68
+ })
69
+
70
+ const req = {
71
+ query: {
72
+ code: 'code-123',
73
+ redirect_uri: 'http://my-callback',
74
+ },
75
+ } as unknown as ApiRequest
76
+
77
+ const res = {
78
+ status: jest.fn().mockReturnThis(),
79
+ json: jest.fn(),
80
+ } as unknown as ApiResponse
81
+
82
+ await routes['/callback'](req, res)
83
+
84
+ expect(mockAdapter.exchangeCodeForToken).toHaveBeenCalledWith('code-123', 'http://my-callback')
85
+ expect(res.status).toHaveBeenCalledWith(200)
86
+ expect(res.json).toHaveBeenCalledWith({ access_token: 'token-abc' })
87
+ })
88
+
89
+ it('should redirect to deep link for mobile platform', async () => {
90
+ GithubAuthApi(mockRouter as unknown as ServerAdapter, '/api/auth/github', {
91
+ adapter: mockAdapter,
92
+ appScheme: 'myapp',
93
+ })
94
+
95
+ const req = {
96
+ query: {
97
+ code: 'code-123',
98
+ },
99
+ } as unknown as ApiRequest
100
+
101
+ const res = {
102
+ status: jest.fn().mockReturnThis(),
103
+ setHeader: jest.fn().mockReturnThis(),
104
+ send: jest.fn(),
105
+ } as unknown as ApiResponse
106
+
107
+ await routes['/callback'](req, res)
108
+
109
+ expect(res.status).toHaveBeenCalledWith(302)
110
+ expect(res.setHeader).toHaveBeenCalledWith(
111
+ 'Location',
112
+ 'myapp://auth/github/callback?token=token-abc'
113
+ )
114
+ })
115
+
116
+ it('should redirect to web URL if webRedirectUri is configured', async () => {
117
+ GithubAuthApi(mockRouter as unknown as ServerAdapter, '/api/auth/github', {
118
+ adapter: mockAdapter,
119
+ webRedirectUri: 'http://localhost:3000/settings?tab=sync',
120
+ })
121
+
122
+ const req = {
123
+ query: {
124
+ code: 'code-123',
125
+ },
126
+ } as unknown as ApiRequest
127
+
128
+ const res = {
129
+ status: jest.fn().mockReturnThis(),
130
+ setHeader: jest.fn().mockReturnThis(),
131
+ send: jest.fn(),
132
+ } as unknown as ApiResponse
133
+
134
+ await routes['/callback'](req, res)
135
+
136
+ expect(res.status).toHaveBeenCalledWith(302)
137
+ expect(res.setHeader).toHaveBeenCalledWith(
138
+ 'Location',
139
+ 'http://localhost:3000/settings?tab=sync&token=token-abc'
140
+ )
141
+ })
142
+ })
@@ -0,0 +1,70 @@
1
+ import { ServerAdapter, ApiRequest, ApiResponse } from '@quatrain/api'
2
+ import { GithubAuthAdapter } from './GithubAuthAdapter'
3
+
4
+ /**
5
+ * Pluggable router endpoints for Github OAuth authentication.
6
+ * Compatible with any Quatrain ServerAdapter (Astro, Express, etc.).
7
+ */
8
+ export function GithubAuthApi(router: ServerAdapter, path: string, options: any = {}) {
9
+ const adapter = options.adapter || GithubAuthAdapter.factory(options.config || {})
10
+ if (!adapter) {
11
+ throw new Error('GithubAuthApi requires a valid GithubAuthAdapter or configuration.')
12
+ }
13
+
14
+ // Route: GET [root]/login
15
+ router.get('/login', async (req: ApiRequest, res: ApiResponse) => {
16
+ const redirectUri = req.query.redirect_uri as string
17
+ const scopes = (req.query.scopes as string || 'repo').split(',')
18
+ const state = req.query.state as string
19
+
20
+ const authUrl = adapter.getAuthorizationUrl(redirectUri, scopes, state)
21
+
22
+ const wantsJson = req.query.json === 'true' || req.headers?.accept?.includes('application/json')
23
+ if (wantsJson) {
24
+ res.status(200).json({ url: authUrl })
25
+ return
26
+ }
27
+
28
+ res.status(302).setHeader('Location', authUrl)
29
+ res.send(`Redirecting to GitHub...`)
30
+ })
31
+
32
+ // Route: GET [root]/callback
33
+ router.get('/callback', async (req: ApiRequest, res: ApiResponse) => {
34
+ const code = req.query.code as string
35
+ const redirectUri = req.query.redirect_uri as string
36
+ const platform = req.query.platform as string
37
+
38
+ if (!code) {
39
+ res.status(400).send('Missing authorization code.')
40
+ return
41
+ }
42
+
43
+ try {
44
+ const tokenData = await adapter.exchangeCodeForToken(code, redirectUri)
45
+
46
+ // Support standard deep link redirect for mobile context if an app scheme is configured
47
+ const appScheme = req.query.app_scheme as string || options.appScheme
48
+ if (appScheme) {
49
+ const redirectUrl = `${appScheme}://auth/github/callback?token=${tokenData.access_token}`
50
+ res.status(302).setHeader('Location', redirectUrl)
51
+ res.send(`Redirecting back to app...`)
52
+ return
53
+ }
54
+
55
+ // Support standard redirect for web context if a target URI is configured
56
+ const webRedirectUri = req.query.web_redirect_uri as string || options.webRedirectUri
57
+ if (webRedirectUri) {
58
+ const separator = webRedirectUri.includes('?') ? '&' : '?'
59
+ const redirectUrl = `${webRedirectUri}${separator}token=${tokenData.access_token}`
60
+ res.status(302).setHeader('Location', redirectUrl)
61
+ res.send(`Redirecting...`)
62
+ return
63
+ }
64
+
65
+ res.status(200).json(tokenData)
66
+ } catch (err: any) {
67
+ res.status(500).send(`OAuth error: ${err.message}`)
68
+ }
69
+ })
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './GithubAuthAdapter'
2
+ export * from './GithubAuthApi'