@ocenkamobi/om-oidc-client-cjs 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/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # @ocenkamobi/om-oidc-client-cjs
2
+
3
+ Клиент сервисных аккаунтов auth.ocenka.mobi для легаси-серверов: CommonJS,
4
+ Node 12+, без зависимостей. Делает две вещи: аутентифицирует запрос к `/token`
5
+ через `client_secret_basic` и кэширует ответ — одинаковый запрос получает тот же
6
+ токен, пока тот не начнёт истекать.
7
+
8
+ Библиотека отвечает за авторизацию сервисных аккаунтов — про реализацию грантов
9
+ в [auth.ocenka.mobi](https://gitlab.com/ocenkamobi/auth.ocenka.mobi/-/blob/main/docs/README.md)
10
+ ничего не знает.
11
+
12
+ Для ESM и Node 20+ —
13
+ [`@ocenkamobi/om-oidc-client`](https://gitlab.com/om-misc/om-oidc-client).
14
+
15
+ ## Подключение
16
+
17
+ ```sh
18
+ npm i @ocenkamobi/om-oidc-client-cjs
19
+ ```
20
+
21
+ ```js
22
+ // oidc.js
23
+ const { createOmOidcClient } = require('@ocenkamobi/om-oidc-client-cjs')
24
+
25
+ module.exports = createOmOidcClient({
26
+ authority: 'https://auth.ocenka.mobi',
27
+ clientId: process.env.CLIENT_ID,
28
+ clientSecret: process.env.CLIENT_SECRET
29
+ })
30
+ ```
31
+
32
+ ```js
33
+ const oidc = require('./oidc')
34
+
35
+ const { access_token: accessToken } = await oidc.token({
36
+ grant_type: 'client_credentials',
37
+ resource: 'https://express.ocenka.mobi',
38
+ scope: 'openid profile org express'
39
+ })
40
+ ```
41
+
42
+ Клиент создаётся один на каждый сервисный аккаунт и держит отдельный кэш.
43
+
44
+ | опция | по умолчанию | значение |
45
+ | -------------- | ------------ | --------------------------------------------------------------- |
46
+ | `authority` | — | сервер авторизации, токены запрашиваются у `${authority}/token` |
47
+ | `clientId` | — | `client_id` сервисного аккаунта |
48
+ | `clientSecret` | — | `client_secret` сервисного аккаунта |
49
+ | `minTtl` | `60` | сколько секунд осталось в токене, чтобы его взяли из кэша |
50
+ | `maxEntries` | `500` | сколько записей помещается в кэш |
51
+
52
+ ## Кэш
53
+
54
+ - Ключ — тело запроса. Порядок полей и скоупов в `scope` значения не имеет,
55
+ пустые поля не учитываются.
56
+ - Токен отдаётся из кэша, пока ему осталось жить не меньше `minTtl` секунд.
57
+ Срок берётся из `expires_in`.
58
+ - Одновременные одинаковые запросы делают один сетевой вызов.
59
+ - Из кэша вытесняются токены, которыми не пользуются: каждое чтение токена из
60
+ кэша поднимает его.
61
+ - Ошибки не кэшируются.
62
+
63
+ ```js
64
+ await oidc.token(body, { minTtl: 300 }) // токен нужен на долгую операцию
65
+ await oidc.token(body, { force: true }) // мимо кэша
66
+ oidc.invalidate(body)
67
+ oidc.clear()
68
+ ```
69
+
70
+ Всё тело вызова `await oidc.token(body)` — это ключ для кэша. Все ключи
71
+ хэшируются.
72
+
73
+ [Отложенный токен](https://gitlab.com/ocenkamobi/auth.ocenka.mobi/-/blob/main/docs/deferred-token.md)
74
+ (`requested_token_type`) выдаётся как любой другой ответ и кэшируется на свои
75
+ 7 суток, но bearer-токеном не является: плагины его в заголовок не поставят, а
76
+ кинут ошибку. Обменивать его на access-токен нужно самому — обычным запросом.
77
+
78
+ ## Ошибки
79
+
80
+ Отказ сервера — `OmOidcTokenError` с полями `status`, `error`,
81
+ `error_description`. Ретраев нет. Запрос к `/token` прерывается через 10 секунд.
82
+
83
+ ## got 11
84
+
85
+ ```js
86
+ const got = require('got')
87
+ const { tokenHooks, withToken } = require('@ocenkamobi/om-oidc-client-cjs/got')
88
+
89
+ const api = got.extend(tokenHooks(oidc))
90
+
91
+ await api.get('https://express.ocenka.mobi/api/orders', withToken({
92
+ grant_type: 'client_credentials',
93
+ resource: 'https://express.ocenka.mobi'
94
+ })).json()
95
+ ```
96
+
97
+ `withToken()` кладёт параметры в `context` запроса. Без него токен не ставится.
98
+
99
+ Дефолт для инстанса задаётся тем же `withToken()` вторым аргументом `extend`, а
100
+ снимается `withToken(false)`:
101
+
102
+ ```js
103
+ const express = got.extend(
104
+ tokenHooks(oidc),
105
+ withToken({ grant_type: 'client_credentials', resource: 'https://express.ocenka.mobi' })
106
+ )
107
+
108
+ await express.get('https://express.ocenka.mobi/api/orders')
109
+ await express.get('https://express.ocenka.mobi/api/health', withToken(false))
110
+ ```
111
+
112
+ ## axios
113
+
114
+ ```js
115
+ const axios = require('axios')
116
+ const { attachToken } = require('@ocenkamobi/om-oidc-client-cjs/axios')
117
+
118
+ const api = axios.create()
119
+ attachToken(api, oidc)
120
+
121
+ await api.get('https://express.ocenka.mobi/api/orders', {
122
+ oidc: { grant_type: 'client_credentials', resource: 'https://express.ocenka.mobi' }
123
+ })
124
+ ```
125
+
126
+ Токен ставится только запросам с `oidc` — без него заголовок не появится.
127
+
128
+ При необходимости параметры можно задать один раз: axios сливает конфиг инстанса
129
+ с конфигом запроса, включая `oidc`.
130
+
131
+ ```js
132
+ const express = axios.create({
133
+ oidc: { grant_type: 'client_credentials', resource: 'https://express.ocenka.mobi' }
134
+ })
135
+ attachToken(express, oidc)
136
+
137
+ await express.get('https://express.ocenka.mobi/api/orders') // с токеном
138
+ await express.get('https://express.ocenka.mobi/api/health', { oidc: false }) // без токена
139
+ ```
140
+
141
+ `attachToken` возвращает функцию, снимающую интерцептор.
142
+
143
+ ## Тесты
144
+
145
+ ```sh
146
+ pnpm test # vitest на текущей ноде
147
+ pnpm test:node12 # смоук в node:12-alpine, нужен Docker
148
+ ```
package/axios.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { AxiosInstance } from 'axios'
2
+ import type { OmOidcClient, OmOidcTokenRequest } from './index'
3
+
4
+ declare module 'axios' {
5
+ interface AxiosRequestConfig {
6
+ /** Параметры запроса токена: с ними запрос уходит с `Authorization: Bearer …` */
7
+ oidc?: OmOidcTokenRequest | false
8
+ }
9
+ }
10
+
11
+ /** Возвращает функцию, снимающую интерцептор */
12
+ export declare function attachToken(instance: AxiosInstance, client: OmOidcClient): () => void
package/axios.js ADDED
@@ -0,0 +1,16 @@
1
+ 'use strict'
2
+
3
+ function attachToken (instance, client) {
4
+ const id = instance.interceptors.request.use(async (config) => {
5
+ if (config.oidc) {
6
+ const { access_token: accessToken, token_type: tokenType } = await client.token(config.oidc)
7
+ if (tokenType !== 'Bearer') throw new Error(`Not Bearer token, token_type: ${tokenType}`)
8
+ config.headers.Authorization = `Bearer ${accessToken}`
9
+ }
10
+ return config
11
+ })
12
+ /** Возвращает функцию, убирающую интерцептор */
13
+ return () => instance.interceptors.request.eject(id)
14
+ }
15
+
16
+ module.exports = { attachToken }
package/cache.js ADDED
@@ -0,0 +1,69 @@
1
+ 'use strict'
2
+
3
+ const crypto = require('crypto')
4
+
5
+ const DEFAULT_MAX_ENTRIES = 500
6
+
7
+ /**
8
+ * Хранит ответы `/token` до истечения их `expires_in`. Вытесняется то, чем дольше всех не
9
+ * пользовались: одноразовые токены обменов не должны выбивать из кэша тот, что нужен всем.
10
+ */
11
+ function createTokenCache (maxEntries = DEFAULT_MAX_ENTRIES) {
12
+ const entries = new Map()
13
+ const pending = new Map()
14
+
15
+ return {
16
+ key: cacheKey,
17
+
18
+ get (key, minTtl) {
19
+ const entry = entries.get(key)
20
+ if (!entry) return undefined
21
+
22
+ const remaining = entry.expiresAt - Date.now()
23
+ if (remaining <= 0) {
24
+ entries.delete(key)
25
+ return undefined
26
+ }
27
+ if (remaining < minTtl * 1000) return undefined
28
+
29
+ // Обращение возвращает запись в конец очереди
30
+ entries.delete(key)
31
+ entries.set(key, entry)
32
+ return entry.value
33
+ },
34
+
35
+ issue (key, load) {
36
+ let promise = pending.get(key)
37
+ if (!promise) {
38
+ promise = load()
39
+ .then((value) => {
40
+ entries.delete(key)
41
+ if (entries.size >= maxEntries) entries.delete(entries.keys().next().value)
42
+ entries.set(key, { value, expiresAt: Date.now() + value.expires_in * 1000 })
43
+ return value
44
+ })
45
+ .finally(() => pending.delete(key))
46
+ pending.set(key, promise)
47
+ }
48
+ return promise
49
+ },
50
+
51
+ delete (key) {
52
+ entries.delete(key)
53
+ },
54
+
55
+ clear () {
56
+ entries.clear()
57
+ }
58
+ }
59
+ }
60
+
61
+ function cacheKey (request) {
62
+ const canonical = Object.entries(request)
63
+ .filter((entry) => entry[1])
64
+ .sort(([a], [b]) => (a < b ? -1 : 1))
65
+ .map(([name, value]) => [name, name === 'scope' ? value.split(/\s+/).filter(Boolean).sort().join(' ') : value])
66
+ return crypto.createHash('sha256').update(JSON.stringify(canonical)).digest('hex')
67
+ }
68
+
69
+ module.exports = { createTokenCache }
package/got.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { ExtendOptions } from 'got'
2
+ import type { OmOidcClient, OmOidcTokenRequest } from './index'
3
+
4
+ /** Хуки для `got.extend()` */
5
+ export declare function tokenHooks(client: OmOidcClient): ExtendOptions
6
+
7
+ /** Опции запроса got с параметрами токена */
8
+ export declare function withToken(request: OmOidcTokenRequest | false): { context: { oidc: OmOidcTokenRequest | false } }
package/got.js ADDED
@@ -0,0 +1,26 @@
1
+ 'use strict'
2
+
3
+ /** Хуки для `got.extend()` */
4
+ function tokenHooks (client) {
5
+ return {
6
+ hooks: {
7
+ beforeRequest: [
8
+ async (options) => {
9
+ const request = options.context.oidc
10
+ if (request) {
11
+ const { access_token: accessToken, token_type: tokenType } = await client.token(request)
12
+ if (tokenType !== 'Bearer') throw new Error(`Not Bearer token, token_type: ${tokenType}`)
13
+ options.headers.authorization = `Bearer ${accessToken}`
14
+ }
15
+ }
16
+ ]
17
+ }
18
+ }
19
+ }
20
+
21
+ /** Опции запроса got с параметрами токена */
22
+ function withToken (request) {
23
+ return { context: { oidc: request } }
24
+ }
25
+
26
+ module.exports = { tokenHooks, withToken }
package/index.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ /** Автодополнение известных значений, но принимается любая строка (аналог LiteralUnion из type-fest) */
2
+ type Known<T extends string> = T | (string & Record<never, never>)
3
+
4
+ export interface OmOidcClientOptions {
5
+ /** Сервер авторизации, токены запрашиваются у `${authority}/token` */
6
+ authority: string
7
+ clientId: string
8
+ clientSecret: string
9
+ /** Минимальное время жизни токена для использования из кэша, в секундах. По умолчанию 60 */
10
+ minTtl?: number
11
+ /** Максимально количество записей в кэше. По умолчанию 500 */
12
+ maxEntries?: number
13
+ }
14
+
15
+ /** Гранты сервисных аккаунтов auth.ocenka.mobi; значение свободное — сервер знает и другие */
16
+ export type OmOidcGrantType = Known<
17
+ | 'client_credentials'
18
+ | 'urn:ietf:params:oauth:grant-type:token-exchange'
19
+ | 'urn:ocenkamobi:params:oauth:grant-type:client-credentials-act'
20
+ | 'urn:ocenkamobi:params:oauth:grant-type:impersonation'
21
+ >
22
+
23
+ /** Идентификаторы типов токенов по RFC 8693 §3; значение свободное */
24
+ export type OmOidcTokenType = Known<
25
+ | 'urn:ietf:params:oauth:token-type:access_token'
26
+ | 'urn:ocenkamobi:params:oauth:token-type:deferred_token'
27
+ >
28
+
29
+ /** Тело запроса к `/token` как есть: грант и параметры задаёт вызывающий */
30
+ export interface OmOidcTokenRequest {
31
+ grant_type: OmOidcGrantType
32
+ resource?: string
33
+ audience?: string
34
+ scope?: string
35
+ subject?: string
36
+ subject_token?: string
37
+ subject_token_type?: OmOidcTokenType
38
+ actor_token?: string
39
+ actor_token_type?: OmOidcTokenType
40
+ requested_token_type?: OmOidcTokenType
41
+ [param: string]: string | undefined
42
+ }
43
+
44
+ export interface OmOidcTokenResponse {
45
+ access_token: string
46
+ token_type: string
47
+ expires_in: number
48
+ scope?: string
49
+ issued_token_type?: string
50
+ }
51
+
52
+ export interface OmOidcTokenOptions {
53
+ /** Переопределяет `minTtl` клиента для этого вызова */
54
+ minTtl?: number
55
+ /** Запросить новый токен, минуя кэш */
56
+ force?: boolean
57
+ }
58
+
59
+ export interface OmOidcClient {
60
+ token(request: OmOidcTokenRequest, options?: OmOidcTokenOptions): Promise<OmOidcTokenResponse>
61
+ invalidate(request: OmOidcTokenRequest): void
62
+ clear(): void
63
+ }
64
+
65
+ export declare class OmOidcTokenError extends Error {
66
+ readonly status: number
67
+ readonly error: string
68
+ readonly error_description?: string
69
+ constructor(status: number, error: string, error_description?: string)
70
+ }
71
+
72
+ export declare function createOmOidcClient(options: OmOidcClientOptions): OmOidcClient
package/index.js ADDED
@@ -0,0 +1,75 @@
1
+ 'use strict'
2
+
3
+ const http = require('http')
4
+ const https = require('https')
5
+ const { createTokenCache } = require('./cache.js')
6
+
7
+ const TIMEOUT = 10000
8
+
9
+ class OmOidcTokenError extends Error {
10
+ constructor (status, error, errorDescription) {
11
+ super(errorDescription ? `${error}: ${errorDescription}` : error)
12
+ this.name = 'OmOidcTokenError'
13
+ this.status = status
14
+ this.error = error
15
+ this.error_description = errorDescription
16
+ }
17
+ }
18
+
19
+ function createOmOidcClient ({ authority, clientId, clientSecret, minTtl = 60, maxEntries }) {
20
+ const endpoint = `${authority.replace(/\/+$/, '')}/token`
21
+ // RFC 6749 §2.3.1: части кодируются как form-urlencoded до base64
22
+ const credentials = Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`)
23
+ const authorization = `Basic ${credentials.toString('base64')}`
24
+ const cache = createTokenCache(maxEntries)
25
+
26
+ async function request (req) {
27
+ const body = new URLSearchParams(Object.entries(req).filter((entry) => entry[1])).toString()
28
+ const res = await post(endpoint, {
29
+ authorization,
30
+ 'content-type': 'application/x-www-form-urlencoded',
31
+ 'content-length': Buffer.byteLength(body)
32
+ }, body)
33
+ let data = {}
34
+ try {
35
+ data = JSON.parse(res.text)
36
+ } catch (e) {}
37
+ if (res.status !== 200 || !data.access_token) {
38
+ throw new OmOidcTokenError(res.status, data.error || 'invalid_response', data.error_description)
39
+ }
40
+ return data
41
+ }
42
+
43
+ return {
44
+ async token (req, options = {}) {
45
+ const key = cache.key(req)
46
+ const ttl = options.minTtl != null ? options.minTtl : minTtl
47
+ const cached = options.force ? undefined : cache.get(key, ttl)
48
+ return cached || cache.issue(key, () => request(req))
49
+ },
50
+ invalidate (req) {
51
+ cache.delete(cache.key(req))
52
+ },
53
+ clear () {
54
+ cache.clear()
55
+ }
56
+ }
57
+ }
58
+
59
+ function post (url, headers, body) {
60
+ return new Promise((resolve, reject) => {
61
+ const client = url.startsWith('http:') ? http : https
62
+ const req = client.request(url, { method: 'POST', headers }, (res) => {
63
+ let text = ''
64
+ res.setEncoding('utf8')
65
+ res.on('data', (chunk) => { text += chunk })
66
+ res.on('end', () => resolve({ status: res.statusCode, text }))
67
+ res.on('error', reject)
68
+ })
69
+ req.setTimeout(TIMEOUT, () => req.destroy(new Error(`Таймаут запроса к ${url}`)))
70
+ req.on('error', reject)
71
+ req.end(body)
72
+ })
73
+ }
74
+
75
+ module.exports = { createOmOidcClient, OmOidcTokenError }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@ocenkamobi/om-oidc-client-cjs",
3
+ "version": "0.1.0",
4
+ "description": "Клиент сервисных аккаунтов auth.ocenka.mobi с кэшем токенов — CommonJS для Node 12",
5
+ "homepage": "https://gitlab.com/om-misc/om-oidc-client-cjs#readme",
6
+ "bugs": {
7
+ "url": "https://gitlab.com/om-misc/om-oidc-client-cjs/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+ssh://git@gitlab.com/om-misc/om-oidc-client-cjs.git"
12
+ },
13
+ "license": "UNLICENSED",
14
+ "author": {
15
+ "name": "@isjs",
16
+ "email": "stas@isjs.ru"
17
+ },
18
+ "type": "commonjs",
19
+ "main": "index.js",
20
+ "types": "index.d.ts",
21
+ "engines": {
22
+ "node": ">=12"
23
+ },
24
+ "exports": {
25
+ ".": {
26
+ "types": "./index.d.ts",
27
+ "default": "./index.js"
28
+ },
29
+ "./axios": {
30
+ "types": "./axios.d.ts",
31
+ "default": "./axios.js"
32
+ },
33
+ "./got": {
34
+ "types": "./got.d.ts",
35
+ "default": "./got.js"
36
+ }
37
+ },
38
+ "files": [
39
+ "index.js",
40
+ "index.d.ts",
41
+ "cache.js",
42
+ "axios.js",
43
+ "axios.d.ts",
44
+ "got.js",
45
+ "got.d.ts"
46
+ ],
47
+ "scripts": {
48
+ "test": "vitest run",
49
+ "test:node12": "docker run --rm -v \"$PWD\":/app -w /app node:12-alpine node test/node12.js"
50
+ },
51
+ "peerDependencies": {
52
+ "axios": ">=0.21",
53
+ "got": "11"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "axios": {
57
+ "optional": true
58
+ },
59
+ "got": {
60
+ "optional": true
61
+ }
62
+ },
63
+ "packageManager": "pnpm@11.11.0",
64
+ "devDependencies": {
65
+ "axios": "^1.20.0",
66
+ "got": "^11.8.6",
67
+ "vitest": "^4.1.11"
68
+ }
69
+ }