passlessjs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,9 @@
1
+ PASSLESS_GOOGLE_CLIENT_ID=
2
+ PASSLESS_GOOGLE_CLIENT_SECRET=
3
+ PASSLESS_GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback
4
+ PASSLESS_YANDEX_CLIENT_ID=
5
+ PASSLESS_YANDEX_CLIENT_SECRET=
6
+ PASSLESS_YANDEX_REDIRECT_URI=http://localhost:3000/auth/yandex/callback
7
+ PASSLESS_RP_NAME=Passless Demo
8
+ PASSLESS_RP_ID=localhost
9
+ PASSLESS_ORIGIN=http://localhost:3000
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # passless
2
+
3
+ Мини-библиотека для Node.js, которая помогает быстро подружиться с OAuth входом через Google и Yandex, а также добавить вход по Passkey (WebAuthn). Конфигурация через `.env`, без лишней магии.
4
+
5
+ ## Установка
6
+
7
+ ```bash
8
+ npm install passless
9
+ ```
10
+
11
+ Требуется Node.js 18+ (используется встроенный `fetch`).
12
+
13
+ ## Переменные окружения
14
+
15
+ Создайте файл `.env` (см. `.env.example`):
16
+
17
+ ```
18
+ PASSLESS_GOOGLE_CLIENT_ID=
19
+ PASSLESS_GOOGLE_CLIENT_SECRET=
20
+ PASSLESS_GOOGLE_REDIRECT_URI=
21
+ PASSLESS_YANDEX_CLIENT_ID=
22
+ PASSLESS_YANDEX_CLIENT_SECRET=
23
+ PASSLESS_YANDEX_REDIRECT_URI=
24
+ PASSLESS_RP_NAME=Passless Demo
25
+ PASSLESS_RP_ID=localhost
26
+ PASSLESS_ORIGIN=http://localhost:3000
27
+ ```
28
+
29
+ ## Быстрый старт (OAuth)
30
+
31
+ ```js
32
+ const express = require('express');
33
+ const { Passless } = require('passless');
34
+ require('dotenv').config();
35
+
36
+ const app = express();
37
+ const passless = new Passless();
38
+
39
+ app.get('/auth/google', (req, res) => {
40
+ const url = passless.getAuthUrl('google', req.query.state || '');
41
+ res.redirect(url);
42
+ });
43
+
44
+ app.get('/auth/google/callback', async (req, res) => {
45
+ const { code } = req.query;
46
+ const result = await passless.exchangeCode('google', code);
47
+ // result.token и result.profile
48
+ res.json(result.profile);
49
+ });
50
+
51
+ app.get('/auth/yandex', (req, res) => {
52
+ const url = passless.getAuthUrl('yandex', req.query.state || '');
53
+ res.redirect(url);
54
+ });
55
+
56
+ app.get('/auth/yandex/callback', async (req, res) => {
57
+ const { code } = req.query;
58
+ const result = await passless.exchangeCode('yandex', code);
59
+ res.json(result.profile);
60
+ });
61
+
62
+ app.listen(3000, () => console.log('http://localhost:3000'));
63
+ ```
64
+
65
+ ## Passkey (WebAuthn) пример
66
+
67
+ В реальном проекте замените `Map` на свою БД. Храните `credentialStore` и `challengeStore` между перезапусками.
68
+
69
+ ```js
70
+ const passless = new Passless({
71
+ passkey: {
72
+ rpId: 'localhost',
73
+ origin: 'http://localhost:3000',
74
+ rpName: 'Passless Demo',
75
+ },
76
+ credentialStore: new Map(),
77
+ challengeStore: new Map(),
78
+ });
79
+
80
+ // Регистрация
81
+ app.get('/passkey/register/options', async (req, res) => {
82
+ const opts = await passless.createPasskeyRegistrationOptions({
83
+ userId: '123',
84
+ username: 'demo',
85
+ displayName: 'Demo User',
86
+ });
87
+ res.json(opts);
88
+ });
89
+
90
+ app.post('/passkey/register/verify', express.json(), async (req, res) => {
91
+ const verification = await passless.verifyPasskeyRegistrationResponse({
92
+ response: req.body,
93
+ expectedChallenge: req.body.expectedChallenge,
94
+ });
95
+ res.json({ verified: verification.verified });
96
+ });
97
+
98
+ // Аутентификация
99
+ app.get('/passkey/authn/options', async (req, res) => {
100
+ const opts = await passless.createPasskeyAuthenticationOptions({ userId: '123' });
101
+ res.json(opts);
102
+ });
103
+
104
+ app.post('/passkey/authn/verify', express.json(), async (req, res) => {
105
+ const verification = await passless.verifyPasskeyAuthenticationResponse({
106
+ response: req.body,
107
+ expectedChallenge: req.body.expectedChallenge,
108
+ });
109
+ res.json({ verified: verification.verified });
110
+ });
111
+ ```
112
+
113
+ ## API поверхностно
114
+
115
+ - `new Passless(config?)` — принимает `google`, `yandex`, `passkey`, а также свои `credentialStore`/`challengeStore` (по умолчанию `Map`).
116
+ - `getAuthUrl(provider, state?)` — вернуть URL авторизации (`provider`: `google` | `yandex`).
117
+ - `exchangeCode(provider, code, overrideRedirectUri?)` — обменять `code` на токен и профиль.
118
+ - `createPasskeyRegistrationOptions({ userId, username, displayName })` — получить options для WebAuthn регистрации.
119
+ - `verifyPasskeyRegistrationResponse({ response, expectedChallenge })` — проверить ответ регистрации.
120
+ - `createPasskeyAuthenticationOptions({ userId })` — options для входа по passkey.
121
+ - `verifyPasskeyAuthenticationResponse({ response, expectedChallenge })` — проверить ответ входа.
122
+
123
+
124
+ ## Ограничения
125
+
126
+ - В примерах используются in-memory хранилища; замените на постоянную БД.
127
+ - Для продакшена добавьте проверку срока жизни `state`/`challenge` и HTTPS.
128
+ - Следите, чтобы `PASSLESS_ORIGIN` и `PASSLESS_RP_ID` совпадали с реальным доменом.
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "passlessjs",
3
+ "version": "0.1.0",
4
+ "description": "Tiny helper for Google, Yandex OAuth and WebAuthn passkeys with .env support",
5
+ "main": "passless.js",
6
+ "license": "MIT",
7
+ "type": "commonjs",
8
+ "scripts": {
9
+ "test": "node -e \"console.log('add tests')\""
10
+ },
11
+ "keywords": [
12
+ "oauth",
13
+ "google",
14
+ "yandex",
15
+ "passkey",
16
+ "webauthn"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "dependencies": {
22
+ "@simplewebauthn/server": "^9.0.1",
23
+ "dotenv": "^16.4.5"
24
+ }
25
+ }
package/passless.js ADDED
@@ -0,0 +1,266 @@
1
+ const dotenv = require('dotenv');
2
+ const {
3
+ generateRegistrationOptions,
4
+ generateAuthenticationOptions,
5
+ verifyRegistrationResponse,
6
+ verifyAuthenticationResponse,
7
+ } = require('@simplewebauthn/server');
8
+
9
+ dotenv.config();
10
+
11
+ const defaultConfig = {
12
+ google: {
13
+ clientId: process.env.PASSLESS_GOOGLE_CLIENT_ID,
14
+ clientSecret: process.env.PASSLESS_GOOGLE_CLIENT_SECRET,
15
+ redirectUri: process.env.PASSLESS_GOOGLE_REDIRECT_URI,
16
+ },
17
+ yandex: {
18
+ clientId: process.env.PASSLESS_YANDEX_CLIENT_ID,
19
+ clientSecret: process.env.PASSLESS_YANDEX_CLIENT_SECRET,
20
+ redirectUri: process.env.PASSLESS_YANDEX_REDIRECT_URI,
21
+ },
22
+ passkey: {
23
+ rpName: process.env.PASSLESS_RP_NAME || 'Passless',
24
+ rpId: process.env.PASSLESS_RP_ID,
25
+ origin: process.env.PASSLESS_ORIGIN,
26
+ },
27
+ };
28
+
29
+ class Passless {
30
+ constructor(config = {}) {
31
+ this.config = {
32
+ google: { ...defaultConfig.google, ...(config.google || {}) },
33
+ yandex: { ...defaultConfig.yandex, ...(config.yandex || {}) },
34
+ passkey: { ...defaultConfig.passkey, ...(config.passkey || {}) },
35
+ };
36
+
37
+ this.challengeStore = config.challengeStore || new Map(); // challenge -> { userId, createdAt }
38
+ this.credentialStore = config.credentialStore || new Map(); // credentialId -> { userId, publicKey, counter }
39
+ }
40
+
41
+ getAuthUrl(provider, state = '') {
42
+ if (provider === 'google') {
43
+ const params = new URLSearchParams({
44
+ client_id: this.config.google.clientId,
45
+ redirect_uri: this.config.google.redirectUri,
46
+ response_type: 'code',
47
+ scope: 'openid email profile',
48
+ access_type: 'offline',
49
+ prompt: 'consent',
50
+ state,
51
+ });
52
+ return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
53
+ }
54
+
55
+ if (provider === 'yandex') {
56
+ const params = new URLSearchParams({
57
+ client_id: this.config.yandex.clientId,
58
+ redirect_uri: this.config.yandex.redirectUri,
59
+ response_type: 'code',
60
+ scope: 'login:info login:email',
61
+ state,
62
+ });
63
+ return `https://oauth.yandex.com/authorize?${params.toString()}`;
64
+ }
65
+
66
+ throw new Error('Unsupported provider. Use "google" or "yandex".');
67
+ }
68
+
69
+ async exchangeCode(provider, code, overrideRedirectUri) {
70
+ if (!code) throw new Error('Authorization code is required.');
71
+
72
+ if (provider === 'google') {
73
+ const token = await this.#exchangeOAuthCode({
74
+ tokenEndpoint: 'https://oauth2.googleapis.com/token',
75
+ clientId: this.config.google.clientId,
76
+ clientSecret: this.config.google.clientSecret,
77
+ code,
78
+ redirectUri: overrideRedirectUri || this.config.google.redirectUri,
79
+ });
80
+
81
+ const profile = await this.#fetchJson(
82
+ 'https://openidconnect.googleapis.com/v1/userinfo',
83
+ token.access_token,
84
+ );
85
+
86
+ return { token, profile };
87
+ }
88
+
89
+ if (provider === 'yandex') {
90
+ const token = await this.#exchangeOAuthCode({
91
+ tokenEndpoint: 'https://oauth.yandex.com/token',
92
+ clientId: this.config.yandex.clientId,
93
+ clientSecret: this.config.yandex.clientSecret,
94
+ code,
95
+ redirectUri: overrideRedirectUri || this.config.yandex.redirectUri,
96
+ });
97
+
98
+ const profile = await this.#fetchJson(
99
+ 'https://login.yandex.ru/info?format=json',
100
+ token.access_token,
101
+ );
102
+
103
+ return { token, profile };
104
+ }
105
+
106
+ throw new Error('Unsupported provider. Use "google" or "yandex".');
107
+ }
108
+
109
+ async createPasskeyRegistrationOptions({ userId, username, displayName }) {
110
+ this.#assertPasskeyConfig();
111
+ if (!userId || !username || !displayName) {
112
+ throw new Error('userId, username, and displayName are required.');
113
+ }
114
+
115
+ const options = await generateRegistrationOptions({
116
+ rpName: this.config.passkey.rpName,
117
+ rpID: this.config.passkey.rpId,
118
+ userID: String(userId),
119
+ userName: String(username),
120
+ userDisplayName: String(displayName),
121
+ attestationType: 'none',
122
+ excludeCredentials: this.#listCredentialsForUser(userId).map((cred) => ({
123
+ id: cred.credentialID,
124
+ type: 'public-key',
125
+ transports: cred.transports,
126
+ })),
127
+ });
128
+
129
+ this.challengeStore.set(options.challenge, { userId, createdAt: Date.now() });
130
+ return options;
131
+ }
132
+
133
+ async verifyPasskeyRegistrationResponse({ response, expectedChallenge }) {
134
+ this.#assertPasskeyConfig();
135
+ const challenge = expectedChallenge || response.challenge;
136
+ const record = this.challengeStore.get(challenge) || null;
137
+ if (!challenge || !record) {
138
+ throw new Error('Unknown or expired registration challenge.');
139
+ }
140
+
141
+ const verification = await verifyRegistrationResponse({
142
+ response,
143
+ expectedChallenge: challenge,
144
+ expectedOrigin: this.config.passkey.origin,
145
+ expectedRPID: this.config.passkey.rpId,
146
+ });
147
+
148
+ if (verification.verified) {
149
+ const { registrationInfo } = verification;
150
+ const credentialID = registrationInfo.credentialID;
151
+ this.credentialStore.set(Buffer.from(credentialID).toString('base64url'), {
152
+ userId: record.userId,
153
+ credentialID,
154
+ credentialPublicKey: registrationInfo.credentialPublicKey,
155
+ counter: registrationInfo.counter,
156
+ transports: response.transports || [],
157
+ });
158
+ this.challengeStore.delete(challenge);
159
+ }
160
+
161
+ return verification;
162
+ }
163
+
164
+ async createPasskeyAuthenticationOptions({ userId }) {
165
+ this.#assertPasskeyConfig();
166
+ const allowCredentials = this.#listCredentialsForUser(userId).map((cred) => ({
167
+ id: cred.credentialID,
168
+ type: 'public-key',
169
+ transports: cred.transports,
170
+ }));
171
+
172
+ const options = await generateAuthenticationOptions({
173
+ rpID: this.config.passkey.rpId,
174
+ allowCredentials,
175
+ });
176
+
177
+ this.challengeStore.set(options.challenge, { userId, createdAt: Date.now() });
178
+ return options;
179
+ }
180
+
181
+ async verifyPasskeyAuthenticationResponse({ response, expectedChallenge }) {
182
+ this.#assertPasskeyConfig();
183
+ const challenge = expectedChallenge || response.challenge;
184
+ const record = this.challengeStore.get(challenge);
185
+ if (!challenge || !record) {
186
+ throw new Error('Unknown or expired authentication challenge.');
187
+ }
188
+
189
+ const credentialIdB64 = response.id || response.rawId;
190
+ const stored = this.credentialStore.get(credentialIdB64);
191
+ if (!stored) {
192
+ throw new Error('Unknown credential.');
193
+ }
194
+
195
+ const verification = await verifyAuthenticationResponse({
196
+ response,
197
+ expectedChallenge: challenge,
198
+ expectedOrigin: this.config.passkey.origin,
199
+ expectedRPID: this.config.passkey.rpId,
200
+ credential: {
201
+ id: stored.credentialID,
202
+ publicKey: stored.credentialPublicKey,
203
+ counter: stored.counter,
204
+ transports: stored.transports,
205
+ },
206
+ });
207
+
208
+ if (verification.verified) {
209
+ stored.counter = verification.authenticationInfo.newCounter;
210
+ this.credentialStore.set(credentialIdB64, stored);
211
+ this.challengeStore.delete(challenge);
212
+ }
213
+
214
+ return verification;
215
+ }
216
+
217
+ async #exchangeOAuthCode({ tokenEndpoint, clientId, clientSecret, code, redirectUri }) {
218
+ const body = new URLSearchParams({
219
+ grant_type: 'authorization_code',
220
+ code,
221
+ client_id: clientId,
222
+ client_secret: clientSecret,
223
+ redirect_uri: redirectUri,
224
+ });
225
+
226
+ const res = await fetch(tokenEndpoint, {
227
+ method: 'POST',
228
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
229
+ body,
230
+ });
231
+
232
+ if (!res.ok) {
233
+ const text = await res.text();
234
+ throw new Error(`Token exchange failed (${res.status}): ${text}`);
235
+ }
236
+
237
+ return res.json();
238
+ }
239
+
240
+ async #fetchJson(url, accessToken) {
241
+ const res = await fetch(url, {
242
+ headers: { Authorization: `Bearer ${accessToken}` },
243
+ });
244
+
245
+ if (!res.ok) {
246
+ const text = await res.text();
247
+ throw new Error(`Profile fetch failed (${res.status}): ${text}`);
248
+ }
249
+
250
+ return res.json();
251
+ }
252
+
253
+ #listCredentialsForUser(userId) {
254
+ return [...this.credentialStore.values()].filter((c) => c.userId === userId);
255
+ }
256
+
257
+ #assertPasskeyConfig() {
258
+ if (!this.config.passkey.rpId || !this.config.passkey.origin) {
259
+ throw new Error('Passkey configuration is missing rpId or origin.');
260
+ }
261
+ }
262
+ }
263
+
264
+ const createPassless = (config) => new Passless(config);
265
+
266
+ module.exports = { Passless, createPassless };