Merge pull request #2757 from mozilla/auth-oauth-2

oauth->auth phase 2
This commit is contained in:
Danny Coates 2019-10-28 12:14:43 -07:00 коммит произвёл GitHub
Родитель ee0ee32777 b5f7df167f
Коммит cd1494af0c
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
169 изменённых файлов: 15685 добавлений и 219 удалений

Просмотреть файл

@ -4,6 +4,6 @@
"client_secret": "852ae8d050d6805a402272e0c776193cfba263ceaa5546dce837191be98db91e",
"content_uri": "http://127.0.0.1:3030",
"profile_uri": "http://127.0.0.1:1111/v1",
"oauth_uri": "http://127.0.0.1:9010/v1"
"oauth_uri": "http://127.0.0.1:9000/v1"
}
}
}

Просмотреть файл

@ -19,7 +19,7 @@ PATH=$PATH:$HOME/.cargo/bin
# Now for concurrently!
../node_modules/.bin/concurrently \
"cd fxa-content-server; npm ci; cp server/config/local.json-dist server/config/local.json" \
"cd fxa-auth-server; npm ci; node ./scripts/gen_keys.js; node ./scripts/gen_vapid_keys.js; node ./fxa-oauth-server/scripts/gen_keys; ../../_scripts/clone-authdb.sh" \
"cd fxa-auth-server; npm ci; node ./scripts/gen_keys.js; NODE_ENV=dev ./scripts/oauth_gen_keys.js; node ./scripts/gen_vapid_keys.js; node ./fxa-oauth-server/scripts/gen_keys; ../../_scripts/clone-authdb.sh" \
"cd fxa-auth-db-mysql; npm ci" \
"cd browserid-verifier; npm ci" \
"cd fxa-js-client; npm ci; npx grunt sjcl" \

Просмотреть файл

@ -46,18 +46,6 @@
"max_restarts": "1",
"min_uptime": "2m"
},
{
"name": "oauth-server PORT 9010",
"script": "../../../_scripts/oauth_mysql.sh",
"cwd": "packages/fxa-auth-server/fxa-oauth-server",
"env": {
"NODE_ENV": "dev",
"HOST": "0.0.0.0",
"DB": "mysql"
},
"max_restarts": "1",
"min_uptime": "2m"
},
{
"name": "profile-server PORT 1111",
"script": "../../_scripts/profile_mysql.sh",

Просмотреть файл

@ -107,18 +107,6 @@
"max_restarts": "1",
"min_uptime": "2m"
},
{
"name": "oauth-server PORT 9010",
"script": "../../../_scripts/oauth_mysql.sh",
"cwd": "packages/fxa-auth-server/fxa-oauth-server",
"env": {
"NODE_ENV": "dev",
"HOST": "0.0.0.0",
"DB": "mysql"
},
"max_restarts": "1",
"min_uptime": "2m"
},
{
"name": "profile-server PORT 1111",
"script": "../../_scripts/profile_mysql.sh",

2
packages/fxa-auth-server/.gitignore поставляемый
Просмотреть файл

@ -1,6 +1,8 @@
/coverage.html
/node_modules
/sandbox
/config/key.json
/config/oldKey.json
/config/public-key.json
/config/secret-key.json
/config/vapid-keys.json

Просмотреть файл

@ -1,49 +1,30 @@
FROM node:10-alpine
FROM node:10-slim
RUN npm install -g npm@6 && rm -rf ~app/.npm /tmp/*
RUN set -x \
&& addgroup --gid 10001 app \
&& adduser --disabled-password \
--gecos '' \
--gid 10001 \
--home /app \
--uid 10001 \
app
RUN apk add --no-cache git make gcc g++ linux-headers openssl python
RUN apt-get update && apt-get -y install git-core python build-essential
RUN addgroup -g 10001 app && \
adduser -D -G app -h /app -u 10001 app
WORKDIR /app
COPY --chown=app:app fxa-auth-server /app
COPY --chown=app:app ["fxa-geodb", "../fxa-geodb/"]
COPY --chown=app:app ["fxa-shared", "../fxa-shared/"]
USER app
COPY fxa-auth-server/package-lock.json package-lock.json
COPY fxa-auth-server/package.json package.json
COPY fxa-auth-server/scripts/download_l10n.sh scripts/download_l10n.sh
COPY fxa-auth-server/scripts/gen_keys.js scripts/gen_keys.js
COPY fxa-auth-server/scripts/gen_vapid_keys.js scripts/gen_vapid_keys.js
COPY fxa-auth-server/fxa-oauth-server/scripts/gen_keys.js fxa-oauth-server/scripts/gen_keys.js
RUN npm ci --production && rm -rf ~app/.npm /tmp/*
COPY fxa-auth-server /app
COPY ["fxa-geodb", "../fxa-geodb/"]
WORKDIR /fxa-geodb
USER root
RUN npm ci
USER app
COPY ["fxa-shared", "../fxa-shared/"]
WORKDIR /fxa-shared
USER root
RUN npm ci
WORKDIR /app
RUN chown app:app /app/config
RUN chown app:app /app/fxa-oauth-server/config
USER app
RUN npm ci --production && rm -rf ~app/.npm /tmp/*
RUN node scripts/gen_keys.js
RUN NODE_ENV=dev node scripts/oauth_gen_keys.js
RUN node scripts/gen_vapid_keys.js
RUN node fxa-oauth-server/scripts/gen_keys.js
USER root
RUN chown root:root /app/config
USER app

Просмотреть файл

@ -1,13 +1,8 @@
FROM fxa-auth-server:build
USER root
RUN rm -rf /app/node_modules
RUN rm -rf /app/fxa-content-server-l10n
COPY fxa-auth-db-mysql fxa-auth-db-mysql
RUN chown -R app /app
USER app
RUN npm ci
WORKDIR /app/fxa-auth-db-mysql
RUN npm ci
# install dev dependencies
WORKDIR /app
RUN npm ci

Просмотреть файл

@ -26,6 +26,7 @@ function run(config) {
}
const log = require('../lib/log')({ ...config.log, statsd });
require('../lib/oauth/logging')(log);
const getGeoData = require('../lib/geodb')(log);
// Force the geo to load and run at startup, not waiting for it to run on
// some route later.
@ -163,6 +164,7 @@ function run(config) {
)
.then(() => {
return {
server,
log: log,
close() {
return new P(resolve => {

Просмотреть файл

@ -148,5 +148,214 @@
"statsd": {
"enabled": true,
"sampleRate": 1
},
"oauthServer": {
"browserid": {
"issuer": "127.0.0.1:9000",
"verificationUrl": "http://127.0.0.1:5050/v2"
},
"contentUrl": "http://127.0.0.1:3030/oauth/",
"clientManagement": {
"enabled": true
},
"clientIdToServiceNames": {
"dcdb5ae7add825d2": "123done",
"98e6508e88680e1a": "fxa-settings"
},
"clients": [
{
"id": "dcdb5ae7add825d2",
"hashedSecret": "289a885946ee316844d9ffd0d725ee714901548a1e6507f1a40fb3c2ae0c99f1",
"name": "123Done",
"imageUri": "https://mozorg.cdn.mozilla.net/media/img/firefox/new/header-firefox.png",
"redirectUri": "http://127.0.0.1:8080/api/oauth",
"trusted": true,
"canGrant": false
},
{
"id": "38a6b9b3a65a1871",
"hashedSecret": "289a885946ee316844d9ffd0d725ee714901548a1e6507f1a40fb3c2ae0c99f1",
"name": "123Done PKCE",
"imageUri": "https://mozorg.cdn.mozilla.net/media/img/firefox/new/header-firefox.png",
"redirectUri": "http://127.0.0.1:8080/?oauth_pkce_redirect=1",
"trusted": true,
"canGrant": false,
"publicClient": true
},
{
"id": "22d74070a481bc73",
"name": "Test Client iOS",
"hashedSecret": "88716ed2927c96cdc0fb7efe57d5f124fb4161066c1ff7f4263069822256ec66",
"redirectUri": "com.mozilla.sandvich:/oauth2redirect/fxa-provider",
"imageUri": "",
"publicClient": true,
"canGrant": false,
"termsUri": "",
"privacyUri": "",
"trusted": true,
"allowedScopes": "https://identity.mozilla.com/apps/oldsync"
},
{
"id": "325b4083e32fe8e7",
"hashedSecret": "ded3c396f28123f3fe6b152784e8eab7357c6806cb5175805602a2cd67f85080",
"name": "321Done Untrusted",
"imageUri": "https://mozorg.cdn.mozilla.net/media/img/firefox/new/header-firefox.png",
"redirectUri": "http://127.0.0.1:10139/api/oauth",
"trusted": false,
"canGrant": false
},
{
"id": "7f368c6886429f19",
"name": "Firefox Notes Android Dev",
"hashedSecret": "9c716ed2927c96cdc0fb7efe57d5f124fb4161066c1ff7f4263069822256ec3f",
"redirectUri": "https://mozilla.github.io/notes/fxa/android-redirect.html",
"imageUri": "",
"canGrant": false,
"termsUri": "",
"privacyUri": "",
"trusted": true,
"allowedScopes": "https://identity.mozilla.com/apps/notes",
"publicClient": true
},
{
"id": "c6d74070a481bc10",
"name": "Firefox Notes Dev",
"hashedSecret": "9c716ed2927c96cdc0fb7efe57d5f124fb4161066c1ff7f4263069822256ec3f",
"redirectUri": "https://dee85c67bd72f3de1f0a0fb62a8fe9b9b1a166d7.extensions.allizom.org/",
"imageUri": "",
"canGrant": false,
"termsUri": "",
"privacyUri": "",
"trusted": true,
"allowedScopes": "https://identity.mozilla.com/apps/notes",
"publicClient": true
},
{
"id": "98e6508e88680e1a",
"hashedSecret": "ba5cfb370fd782f7eae1807443ab816288c101a54c0d80a09063273c86d3c435",
"name": "Firefox Accounts Settings",
"imageUri": "https://example2.domain/logo",
"redirectUri": "https://example2.domain/return?foo=bar",
"trusted": true,
"canGrant": true,
"publicClient": true
},
{
"name": "FxA OAuth Console",
"redirectUri": "http://127.0.0.1:10137/oauth/redirect",
"imageUri": "http://127.0.0.1:10137/assets/firefox.png",
"id": "24bdbfa45cd300c5",
"hashedSecret": "dfe56d5c816d6b7493618f6a1567cfed4aa9c25f85d59c6804631c48774ba545",
"trusted": true,
"canGrant": false
},
{
"name": "Firefox",
"id": "5882386c6d801776",
"hashedSecret": "71b5283536f1f1c331eca2f75c58a5947d7a7ac54164eadb4b33a889afe89fbf",
"imageUri": "",
"redirectUri": "urn:ietf:wg:oauth:2.0:oob",
"trusted": true,
"canGrant": true
},
{
"name": "Fennec",
"id": "3332a18d142636cb",
"hashedSecret": "99ee06fa07919c5208694d34d761fa95ee5a0bbbaad3f3ebaa6042b04a6bdec1",
"imageUri": "",
"redirectUri": "urn:ietf:wg:oauth:2.0:oob",
"trusted": true,
"canGrant": true
},
{
"name": "Firefox Accounts",
"id": "ea3ca969f8c6bb0d",
"hashedSecret": "744559ea3d0f69eb5185cbd5b176a38e09d013c6459dbb3cbc25b4c5b165d33f",
"imageUri": "",
"redirectUri": "urn:ietf:wg:oauth:2.0:oob",
"trusted": true,
"canGrant": true,
"publicClient": true
},
{
"id": "3c49430b43dfba77",
"name": "Android Components Reference Browser",
"hashedSecret": "a7ee3482fab1782f5d3945cde06bb911605a8dfc1a45e4b77bc76615d5671e51",
"imageUri": "",
"redirectUri": "http://127.0.0.1:3030/oauth/success/3c49430b43dfba77",
"canGrant": true,
"trusted": true,
"allowedScopes": "https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session",
"publicClient": true
},
{
"id": "a2270f727f45f648",
"name": "Fenix",
"hashedSecret": "4a892c55feaceb4ef2dbfffaaaa3d8eea94b5c205c815dddfc90170741cd4c19",
"imageUri": "",
"redirectUri": "http://127.0.0.1:3030/oauth/success/a2270f727f45f648",
"canGrant": true,
"trusted": true,
"allowedScopes": "https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session",
"publicClient": true
},
{
"id": "59cceb6f8c32317c",
"name": "Firefox Accounts Subscriptions",
"hashedSecret": "220e560d48cf91dbba0219b986ca242a0b278eab8467bb07442fdfed1b245788",
"redirectUri": "http://127.0.0.1:3031/",
"imageUri": "",
"canGrant": true,
"termsUri": "",
"privacyUri": "",
"trusted": true,
"publicClient": true
},
{
"id": "d15ab1edd15ab1ed",
"hashedSecret": "289a885946ee316844d9ffd0d725ee714901548a1e6507f1a40fb3c2ae0c99f1",
"name": "Disabled Client",
"imageUri": "https://mozorg.cdn.mozilla.net/media/img/firefox/new/header-firefox.png",
"redirectUri": "http://127.0.0.1:8080/?oauth_pkce_redirect=1",
"trusted": true,
"canGrant": false,
"publicClient": true
}
],
"disabledClients": ["d15ab1edd15ab1ed"],
"localRedirects": true,
"jwtAccessTokens": {
"enabled": true,
"enabledClientIds": [
"98e6508e88680e1a",
"dcdb5ae7add825d2",
"325b4083e32fe8e7"
]
},
"openid": {
"issuer": "http://127.0.0.1:3030",
"keyFile": "config/key.json",
"newKeyFile": "config/newKey.json",
"oldKeyFile": "config/oldKey.json"
},
"ppid": {
"enabled": true,
"enabledClientIds": ["325b4083e32fe8e7"],
"rotatingClientIds": ["325b4083e32fe8e7"],
"rotationPeriodMS": 30000,
"salt": "a new ppid salt"
},
"allowHttpRedirects": true,
"authServerSecrets": ["megaz0rd", "whatever"],
"scopes": [
{
"scope": "https://identity.mozilla.com/apps/notes",
"hasScopedKeys": true
},
{
"scope": "https://identity.mozilla.com/apps/oldsync",
"hasScopedKeys": true
}
]
}
}

Просмотреть файл

@ -689,7 +689,7 @@ const conf = convict({
url: {
format: 'url',
doc: 'URL at which to verify OAuth tokens',
default: 'http://127.0.0.1:9010',
default: 'http://127.0.0.1:9000',
env: 'OAUTH_URL',
},
keepAlive: {
@ -762,6 +762,381 @@ const conf = convict({
},
},
},
oauthServer: {
admin: {
whitelist: {
doc: 'An array of regexes. Passing any one will get through.',
default: ['@mozilla\\.com$'],
},
},
api: {
version: {
doc: 'Number part of versioned endpoints - ex: /v1/token',
default: 1,
},
},
allowHttpRedirects: {
arg: 'allowHttpRedirects',
doc: 'If true, then it allows http OAuth redirect uris',
env: 'ALLOW_HTTP_REDIRECTS',
format: 'Boolean',
default: false,
},
audience: {
doc: 'audience for oauth JWTs',
format: 'url',
default: 'http://127.0.0.1:9000',
env: 'OAUTH_URL',
},
auth: {
poolee: {
timeout: {
default: '30 seconds',
doc: 'Time in milliseconds to wait for auth server query completion',
env: 'AUTH_POOLEE_TIMEOUT',
format: 'duration',
},
maxPending: {
default: 1000,
doc: 'Number of pending requests to fxa-auth-server to allow',
env: 'AUTH_POOLEE_MAX_PENDING',
format: 'int',
},
},
jwtSecretKey: {
default: 'megaz0rd',
doc: 'Shared secret for signing oauth-to-auth server JWT assertions',
format: 'String',
env: 'AUTH_SERVER_SHARED_SECRET',
},
url: {
default: 'http://127.0.0.1:9000',
doc: 'The auth-server public URL',
env: 'AUTH_SERVER_URL',
format: 'url',
},
},
authServerSecrets: {
doc:
'Comma-separated list of secret keys for verifying server-to-server JWTs',
env: 'AUTH_SERVER_SECRETS',
format: 'Array',
default: [],
},
browserid: {
issuer: {
doc: 'We only accept assertions from this issuer',
env: 'ISSUER',
default: 'api.accounts.firefox.com',
},
maxSockets: {
doc: 'The maximum number of connections that the pool can use at once.',
env: 'BROWSERID_MAX_SOCKETS',
default: 10,
},
verificationUrl: {
doc: 'URL to the remote verifier we will use for fxa-assertions',
format: 'url',
env: 'VERIFICATION_URL',
default: 'https://verifier.accounts.firefox.com/v2',
},
},
clients: {
doc: 'Some pre-defined clients that will be inserted into the DB',
env: 'OAUTH_CLIENTS',
format: 'Array',
default: [],
},
clientManagement: {
enabled: {
doc:
'Enable client management in OAuth server routes. Do NOT set this to true in production.',
default: false,
format: 'Boolean',
env: 'CLIENT_MANAGEMENT_ENABLED',
},
},
clientIdToServiceNames: {
doc:
'Mappings from client id to service name: { "id1": "name-1", "id2": "name-2" }',
default: {},
format: 'Object',
env: 'OAUTH_CLIENT_IDS',
},
disabledClients: {
doc:
'Comma-separated list of client ids for which service should be temporarily refused',
env: 'OAUTH_CLIENTS_DISABLED',
format: 'Array',
default: [],
},
scopes: {
doc: 'Some pre-defined list of scopes that will be inserted into the DB',
env: 'OAUTH_SCOPES',
format: 'Array',
default: [],
},
clientAddressDepth: {
doc: 'location of the client ip address in the remote address chain',
env: 'CLIENT_ADDRESS_DEPTH',
default: 3,
},
contentUrl: {
doc: 'URL to UI page in fxa-content-server that starts OAuth flow',
format: 'url',
env: 'CONTENT_URL',
default: 'https://accounts.firefox.com/oauth/',
},
db: {
driver: {
env: 'DB',
format: ['mysql'],
default: 'mysql',
},
autoUpdateClients: {
doc: 'If true, update clients from config file settings',
env: 'DB_AUTO_UPDATE_CLIENTS',
format: 'Boolean',
default: true,
},
},
env: {
arg: 'node-env',
doc: 'The current node.js environment',
env: 'NODE_ENV',
format: ['dev', 'test', 'stage', 'prod'],
default: 'prod',
},
events: {
enabled: {
doc:
'Whether or not config.events has to be properly set in production',
default: true,
format: 'Boolean',
env: 'EVENTS_ENABLED',
},
region: {
doc: 'AWS Region of fxa account events',
default: '',
format: 'String',
env: 'FXA_EVENTS_REGION',
},
queueUrl: {
doc: 'SQS queue url for fxa account events',
default: '',
format: 'String',
env: 'FXA_EVENTS_QUEUE_URL',
},
},
expiration: {
accessToken: {
doc: 'Access Tokens maximum expiration (can live shorter)',
format: 'duration',
default: '2 weeks',
env: 'FXA_EXPIRATION_ACCESS_TOKEN',
},
accessTokenExpiryEpoch: {
doc: 'Timestamp after which access token expiry is actively enforced',
format: 'timestamp',
default: '2017-01-01',
env: 'FXA_EXPIRATION_ACCESS_TOKEN_EXPIRY_EPOCH',
},
code: {
doc: 'Clients must trade codes for tokens before they expire',
format: 'duration',
default: '15 minutes',
env: 'FXA_EXPIRATION_CODE',
},
},
refreshToken: {
updateAfter: {
doc: 'lastUsedAt only gets updated after this delay',
format: 'duration',
default: '24 hours',
env: 'FXA_REFRESH_TOKEN_UPDATE_AFTER',
},
},
git: {
commit: {
doc: 'Commit SHA when in stage/production',
format: 'String',
default: '',
},
},
jwtAccessTokens: {
enabled: {
doc: 'Whether or not JWT access tokens are enabled',
default: true,
format: 'Boolean',
env: 'JWT_ACCESS_TOKENS_ENABLED',
},
enabledClientIds: {
doc: 'JWT access tokens are only returned for client_ids in this list',
default: [],
format: 'Array',
env: 'JWT_ACCESS_TOKENS_ENABLED_CLIENT_IDS',
},
},
localRedirects: {
doc: 'When true, `localhost` and `127.0.0.1` always are legal redirects.',
default: false,
env: 'FXA_OAUTH_LOCAL_REDIRECTS',
},
mysql: {
createSchema: { default: true, env: 'CREATE_MYSQL_SCHEMA' },
user: { default: 'root', env: 'MYSQL_USERNAME' },
password: { default: '', env: 'MYSQL_PASSWORD' },
database: { default: 'fxa_oauth', env: 'MYSQL_DATABASE' },
host: { default: '127.0.0.1', env: 'MYSQL_HOST' },
port: { default: '3306', env: 'MYSQL_PORT' },
connectionLimit: {
doc: 'The maximum number of connections that the pool can use at once.',
default: 10,
env: 'MYSQL_CONNECTION_LIMIT',
},
timezone: {
default: 'Z',
doc:
'The timezone configured on the MySQL server. This is used to type cast server date/time values to JavaScript `Date` object. Can be `local`, `Z`, or an offset in the form of or an offset in the form +HH:MM or -HH:MM.',
env: 'MYSQL_TIMEZONE',
format: 'String',
},
},
openid: {
keyFile: {
doc: 'Path to private key JWK to sign various kinds of JWT tokens',
default: '',
format: 'String',
env: 'FXA_OPENID_KEYFILE',
},
newKeyFile: {
doc:
'Path to private key JWK that will be used to sign JWTs in the future',
default: '',
format: 'String',
env: 'FXA_OPENID_NEWKEYFILE',
},
oldKeyFile: {
doc: 'Path to public key JWK that was used to sign JWTs in the past',
default: '',
format: 'String',
env: 'FXA_OPENID_OLDKEYFILE',
},
key: {
doc: 'Private key JWK to sign various kinds of JWT tokens',
default: {},
env: 'FXA_OPENID_KEY',
},
newKey: {
doc: 'Private key JWK that will be used to sign JWTs in the future',
default: {},
env: 'FXA_OPENID_NEWKEY',
},
oldKey: {
doc: 'Public key JWK that was used to sign JWTs in the past',
default: {},
env: 'FXA_OPENID_OLDKEY',
},
unsafelyAllowMissingActiveKey: {
doc:
'Do not error out if there is no active key; should only be used when initializing keys',
default: false,
format: 'Boolean',
env: 'FXA_OPENID_UNSAFELY_ALLOW_MISSING_ACTIVE_KEY',
},
issuer: {
doc: 'The value of the `iss` property of the id_token',
default: 'https://accounts.firefox.com',
env: 'FXA_OPENID_ISSUER',
},
ttl: {
doc: 'Number of milliseconds until id_token should expire',
default: '5 minutes',
format: 'duration',
env: 'FXA_OPENID_TTL',
},
},
ppid: {
enabled: {
doc: 'Whether pairwise pseudonymous identifiers (PPIDs) are enabled',
default: false,
format: 'Boolean',
env: 'PPID_ENABLED',
},
enabledClientIds: {
doc: 'client_ids that receive PPIDs',
default: [],
format: 'Array',
env: 'PPID_CLIENT_IDS',
},
rotatingClientIds: {
doc:
'client_ids that receive automatically rotating PPIDs based on server time',
default: [],
format: 'Array',
env: 'PPID_ROTATING_CLIENT_IDS',
},
rotationPeriodMS: {
doc: 'salt used in HKDF for PPIDs, converted to milliseconds',
format: 'duration',
default: '6 hours',
env: 'PPID_ROTATION_PERIOD',
},
salt: {
doc: 'salt used in HKDF for PPIDs',
default: 'YOU MUST CHANGE ME',
format: 'String',
env: 'PPID_SALT',
},
},
publicUrl: {
format: 'url',
default: 'http://127.0.0.1:9000',
env: 'PUBLIC_URL',
},
server: {
host: { env: 'HOST', default: '127.0.0.1' },
port: { env: 'PORT', format: 'port', default: 9000 },
},
serverInternal: {
host: { env: 'HOST_INTERNAL', default: '127.0.0.1' },
port: { env: 'PORT_INTERNAL', format: 'port', default: 9011 },
},
i18n: {
defaultLanguage: {
default: 'en',
format: 'String',
env: 'DEFAULT_LANG',
},
supportedLanguages: {
default: [],
format: 'Array',
env: 'SUPPORTED_LANGS',
},
},
unique: {
clientSecret: {
doc: 'Bytes of generated client_secrets',
default: 32,
},
code: { doc: 'Bytes of generated codes', default: 32 },
id: { doc: 'Bytes of generated DB ids', default: 8 },
token: { doc: 'Bytes of generated tokens', default: 32 },
developerId: { doc: 'Bytes of generated developer ids', default: 16 },
},
cacheControl: {
doc:
'Hapi: a string with the value of the "Cache-Control" header when caching is disabled',
format: 'String',
default: 'private, no-cache, no-store, must-revalidate',
},
sentryDsn: {
doc: 'Sentry DSN for error and log reporting',
default: '',
format: 'String',
env: 'SENTRY_DSN',
},
},
metrics: {
flow_id_key: {
default: 'YOU MUST CHANGE ME',
@ -1187,7 +1562,7 @@ let envConfig = path.join(__dirname, `${conf.get('env')}.json`);
envConfig = `${envConfig},${process.env.CONFIG_FILES || ''}`;
const files = envConfig.split(',').filter(fs.existsSync);
conf.loadFile(files);
conf.validate({ allowed: 'strict' });
conf.validate();
// set the public url as the issuer domain for assertions
conf.set('domain', url.parse(conf.get('publicUrl')).host);
@ -1237,6 +1612,63 @@ if (conf.get('env') === 'dev') {
}
}
if (conf.get('oauthServer.openid.keyFile')) {
const keyFile = path.resolve(
__dirname,
'..',
conf.get('oauthServer.openid.keyFile')
);
conf.set('oauthServer.openid.keyFile', keyFile);
// If the file doesnt exist, or contains an empty object, then there's no active key.
conf.set('oauthServer.openid.key', null);
if (fs.existsSync(keyFile)) {
const key = JSON.parse(fs.readFileSync(keyFile));
if (key && Object.keys(key).length > 0) {
conf.set('oauthServer.openid.key', key);
}
}
} else if (Object.keys(conf.get('oauthServer.openid.key')).length === 0) {
conf.set('oauthServer.openid.key', null);
}
if (conf.get('oauthServer.openid.newKeyFile')) {
const newKeyFile = path.resolve(
__dirname,
'..',
conf.get('oauthServer.openid.newKeyFile')
);
conf.set('oauthServer.openid.newKeyFile', newKeyFile);
// If the file doesnt exist, or contains an empty object, then there's no new key.
conf.set('oauthServer.openid.newKey', null);
if (fs.existsSync(newKeyFile)) {
const newKey = JSON.parse(fs.readFileSync(newKeyFile));
if (newKey && Object.keys(newKey).length > 0) {
conf.set('oauthServer.openid.newKey', newKey);
}
}
} else if (Object.keys(conf.get('oauthServer.openid.newKey')).length === 0) {
conf.set('oauthServer.openid.newKey', null);
}
if (conf.get('oauthServer.openid.oldKeyFile')) {
const oldKeyFile = path.resolve(
__dirname,
'..',
conf.get('oauthServer.openid.oldKeyFile')
);
conf.set('oauthServer.openid.oldKeyFile', oldKeyFile);
// If the file doesnt exist, or contains an empty object, then there's no old key.
conf.set('oauthServer.openid.oldKey', null);
if (fs.existsSync(oldKeyFile)) {
const oldKey = JSON.parse(fs.readFileSync(oldKeyFile));
if (oldKey && Object.keys(oldKey).length > 0) {
conf.set('oauthServer.openid.oldKey', oldKey);
}
}
} else if (Object.keys(conf.get('oauthServer.openid.oldKey')).length === 0) {
conf.set('oauthServer.openid.oldKey', null);
}
// Ensure secrets are not set to their default values in production.
if (conf.get('isProduction')) {
const SECRET_SETTINGS = [

Просмотреть файл

@ -20,23 +20,14 @@ const envConfig = path.join(
`${conf.get('env')}.json`
);
// This is sneaky and gross, but temporary.
if (process.mainModule.filename.includes('key_server')) {
if (fs.existsSync(envConfig)) {
conf.loadFile(envConfig);
}
conf.set('mysql.createSchema', false);
conf.validate();
} else {
const files = (envConfig + ',' + process.env.CONFIG_FILES)
.split(',')
.filter(fs.existsSync);
conf.loadFile(files);
conf.validate({
allowed: 'strict',
});
conf.set('audience', conf.get('publicUrl'));
}
const files = (envConfig + ',' + process.env.CONFIG_FILES)
.split(',')
.filter(fs.existsSync);
conf.loadFile(files);
conf.validate({
allowed: 'strict',
});
conf.set('audience', conf.get('publicUrl'));
if (conf.get('openid.keyFile')) {
const keyFile = path.resolve(

Просмотреть файл

@ -6,6 +6,7 @@
const inherits = require('util').inherits;
const messages = require('joi/lib/language').errors;
const OauthError = require('./oauth/error');
const ERRNO = {
SERVER_CONFIG_ERROR: 100,
@ -200,6 +201,9 @@ AppError.translate = function(request, response) {
if (response instanceof AppError) {
return response;
}
if (OauthError.isOauthRoute(request && request.route.path)) {
return OauthError.translate(response);
}
const payload = response.output.payload;
const reason = response.reason;
if (!payload) {

Просмотреть файл

@ -0,0 +1,160 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use stict';
/* Utilities for verifing signed identity assertions.
*
* This service accepts two different kinds of identity assertions
* for authenticating the caller:
*
* - A JWT, signed by one of a fixed set of trusted server-side secret
* HMAC keys.
* - A BrowserID assertion bundle, signed via BrowserID's public key
* discovery mechanisms.
*
* The former is much simpler and easier to verify, so much so that
* we do it inline in the server process. The later is much more
* complicated and we need to call out to an external verifier process.
* We hope to eventually phase out support for BrowserID assertions.
*
*/
const P = require('../promise');
const Joi = require('joi');
const validators = require('./validators');
const AppError = require('./error');
const config = require('../../config');
const logger = require('./logging')('assertion');
const { verifyJWT } = require('../../lib/serverJWT');
const HEX_STRING = /^[0-9a-f]+$/;
// FxA sends several custom claims, ref
// https://github.com/mozilla/fxa/blob/master/packages/fxa-auth-server/docs/api.md#post-certificatesign
const CLAIMS_SCHEMA = Joi.object({
uid: Joi.string()
.length(32)
.regex(HEX_STRING)
.required(),
'fxa-generation': Joi.number()
.integer()
.min(0)
.required(),
'fxa-verifiedEmail': Joi.string()
.max(255)
.required(),
'fxa-lastAuthAt': Joi.number()
.integer()
.min(0)
.required(),
iat: Joi.number()
.integer()
.min(0)
.optional(),
'fxa-tokenVerified': Joi.boolean().optional(),
'fxa-sessionTokenId': validators.sessionTokenId.optional(),
'fxa-amr': Joi.array()
.items(Joi.string().alphanum())
.optional(),
'fxa-aal': Joi.number()
.integer()
.min(0)
.max(3)
.optional(),
'fxa-profileChangedAt': Joi.number()
.integer()
.min(0)
.optional(),
'fxa-keysChangedAt': Joi.number()
.integer()
.min(0)
.optional(),
}).options({ stripUnknown: true });
const validateClaims = P.promisify(CLAIMS_SCHEMA.validate, {
context: CLAIMS_SCHEMA,
});
const AUDIENCE = config.get('oauthServer.audience');
const ALLOWED_ISSUER = config.get('oauthServer.browserid.issuer');
const request = P.promisify(
require('request').defaults({
url: config.get('oauthServer.browserid.verificationUrl'),
pool: {
maxSockets: config.get('oauthServer.browserid.maxSockets'),
},
}),
{ multiArgs: true }
);
function error(assertion, msg, val) {
logger.info('invalidAssertion', { assertion, msg, val });
throw AppError.invalidAssertion();
}
// Verify a BrowserID assertion,
// by posting to an external verifier service.
async function verifyBrowserID(assertion) {
let res, body;
try {
[res, body] = await request({
method: 'POST',
json: {
assertion: assertion,
audience: AUDIENCE,
},
});
} catch (err) {
logger.error('verify.error', err);
throw err;
}
if (!res || !body || body.status !== 'okay') {
return error(assertion, 'non-okay response', body);
}
const email = body.email;
const parts = email.split('@');
if (parts.length !== 2 || parts[1] !== ALLOWED_ISSUER) {
return error(assertion, 'invalid email', email);
}
if (body.issuer !== ALLOWED_ISSUER) {
return error(assertion, 'invalid issuer', body.issuer);
}
const uid = parts[0];
const claims = body.idpClaims || {};
claims.uid = uid;
return claims;
}
module.exports = async function verifyAssertion(assertion) {
// We can differentiate between JWTs and BrowserID assertions
// because the former cannot contain "~" while the later always do.
let claims;
if (/~/.test(assertion)) {
claims = await verifyBrowserID(assertion);
} else {
try {
claims = await verifyJWT(
assertion,
AUDIENCE,
ALLOWED_ISSUER,
config.get('oauthServer.authServerSecrets'),
error
);
claims.uid = claims.sub;
} catch (err) {
return error(assertion, err.message);
}
}
try {
return await validateClaims(claims);
} catch (err) {
return error(assertion, err, claims);
}
};

Просмотреть файл

@ -0,0 +1,50 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const ScopeSet = require('../../../fxa-shared').oauth.scopes;
const AppError = require('./error');
const logger = require('./logging')('server.auth_bearer');
const token = require('./token');
const validators = require('./validators');
const authName = 'authBearer';
const authOAuthScope = ScopeSet.fromArray(['clients:write']);
exports.AUTH_STRATEGY = authName;
exports.AUTH_SCHEME = authName;
exports.SCOPE_CLIENT_WRITE = authOAuthScope;
exports.strategy = function() {
return {
authenticate: async function authBearerStrategy(req, h) {
var auth = req.headers.authorization;
logger.debug(authName + '.check', { header: auth });
if (!auth || auth.indexOf('Bearer ') !== 0) {
throw AppError.unauthorized('Bearer token not provided');
}
var tok = auth.split(' ')[1];
if (validators.accessToken.validate(tok).error) {
throw AppError.unauthorized('Illegal Bearer token');
}
return token.verify(tok).then(
function tokenFound(details) {
logger.info(authName + '.success', details);
details.scope = details.scope.getScopeValues();
return h.authenticated({
credentials: details,
});
},
function noToken(err) {
logger.debug(authName + '.error', err);
throw AppError.unauthorized('Bearer token invalid');
}
);
},
};
};

Просмотреть файл

@ -0,0 +1,66 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const ScopeSet = require('../../../fxa-shared').oauth.scopes;
const AppError = require('./error');
const logger = require('./logging')('server.auth');
const token = require('./token');
const validators = require('./validators');
const WHITELIST = require('../../config')
.get('oauthServer.admin.whitelist')
.map(function(re) {
return new RegExp(re);
});
exports.AUTH_STRATEGY = 'dogfood';
exports.AUTH_SCHEME = 'bearer';
exports.SCOPE_CLIENT_MANAGEMENT = ScopeSet.fromArray(['oauth']);
exports.strategy = function() {
logger.verbose('auth_client.whitelist', WHITELIST);
return {
authenticate: async function dogfoodStrategy(req, h) {
var auth = req.headers.authorization;
logger.debug('check.auth', { header: auth });
if (!auth || auth.indexOf('Bearer ') !== 0) {
throw AppError.unauthorized('Bearer token not provided');
}
var tok = auth.split(' ')[1];
if (!validators.HEX_STRING.test(tok)) {
throw AppError.unauthorized('Illegal Bearer token');
}
return token.verify(tok).then(
function tokenFound(details) {
if (details.scope.contains(exports.SCOPE_CLIENT_MANAGEMENT)) {
logger.debug('check.whitelist');
var blocked = !WHITELIST.some(function(re) {
return re.test(details.email);
});
if (blocked) {
logger.warn('whitelist.blocked', {
email: details.email,
token: tok,
});
throw AppError.forbidden();
}
}
logger.info('success', details);
details.scope = details.scope.getScopeValues();
return h.authenticated({ credentials: details });
},
function noToken(err) {
logger.debug('error', err);
throw AppError.unauthorized('Bearer token invalid');
}
);
},
};
};

Просмотреть файл

@ -0,0 +1,150 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const crypto = require('crypto');
const buf = require('buf').hex;
const Joi = require('joi');
const AppError = require('./error');
const validators = require('./validators');
const db = require('./db');
const encrypt = require('./encrypt');
const logger = require('./logging')('client');
// Client credentials can be provided in either the Authorization header
// or the request body, but not both.
// These are some re-useable validators to assert that requirement.
module.exports.clientAuthValidators = {
headers: Joi.object({
authorization: Joi.string()
.regex(validators.BASIC_AUTH_HEADER)
.optional(),
}).options({ allowUnknown: true, stripUnknown: false }),
// The use of `$headers` here is Joi syntax for a "context reference"
// as described at https://hapi.dev/family/joi/?v=16.1.4#refkey-options.
// Hapi provides the headers as part of the context when validating request payload,
// as noted in https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsresponseschema.
clientId: validators.clientId.when('$headers.authorization', {
is: Joi.string().required(),
then: Joi.forbidden(),
}),
clientSecret: validators.clientSecret.when('$headers.authorization', {
is: Joi.string().required(),
then: Joi.forbidden(),
}),
};
/**
* Extract and normalize client credentials from a request.
* Clients may provide credentials in either the Authorization header
* or the request body, but not both.
*
* @param {Object} headers the headers from the request
* @param {Object} params the payload from the request
* @returns {Object} credentials
* @param {String} client_id
* @param {String} [client_secret]
*/
module.exports.getClientCredentials = function getClientCredentials(
headers,
params
) {
const creds = {};
creds.client_id = params.client_id;
if (params.client_secret) {
creds.client_secret = params.client_secret;
}
// Clients are allowed to provide credentials in either
// the Authorization header or request body, but not both.
if (headers.authorization) {
const authzMatch = validators.BASIC_AUTH_HEADER.exec(headers.authorization);
const err = new AppError.invalidRequestParameter({
keys: ['authorization'],
});
if (!authzMatch || creds.client_id || creds.client_secret) {
throw err;
}
const [clientId, clientSecret, ...rest] = Buffer.from(
authzMatch[1],
'base64'
)
.toString()
.split(':');
if (rest.length !== 0) {
throw err;
}
creds.client_id = Joi.attempt(clientId, validators.clientId, err);
creds.client_secret = Joi.attempt(
clientSecret,
validators.clientSecret,
err
);
}
return creds;
};
/**
* Authenticate a request made by an OAuth client, using credentials from
* either the Authorization header or request body parameters.
*
* @param {Object} headers the headers from the request
* @param {Object} params the payload from the request
* @returns {Promise} resolves with info about the client, or
* rejects if invalid credentials were provided
*/
module.exports.authenticateClient = async function authenticateClient(
headers,
params
) {
const creds = exports.getClientCredentials(headers, params);
const client = await getClientById(creds.client_id);
// Public clients can't be authenticated in any useful way,
// and should never submit a client_secret.
if (client.publicClient) {
if (creds.client_secret) {
throw new AppError.invalidRequestParameter({ keys: ['client_secret'] });
}
return client;
}
// Check client_secret against both current and previous stored secrets,
// to allow for seamless rotation of the secret.
if (!creds.client_secret) {
throw new AppError.invalidRequestParameter({ keys: ['client_secret'] });
}
const submitted = encrypt.hash(buf(creds.client_secret));
const stored = client.hashedSecret;
if (crypto.timingSafeEqual(submitted, stored)) {
return client;
}
const storedPrevious = client.hashedSecretPrevious;
if (storedPrevious) {
if (crypto.timingSafeEqual(submitted, storedPrevious)) {
logger.info('client.matchSecretPrevious', { client: client.id });
return client;
}
}
logger.info('client.mismatchSecret', { client: client.id });
logger.verbose('client.mismatchSecret.details', {
submitted: submitted,
db: stored,
dbPrevious: storedPrevious,
});
throw AppError.incorrectSecret(client.id);
};
async function getClientById(clientId) {
const client = await db.getClient(buf(clientId));
if (!client) {
logger.debug('client.notFound', { id: clientId });
throw AppError.unknownClient(clientId);
}
return client;
}

Просмотреть файл

@ -0,0 +1,88 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/***
* This module consists of helpers that are used by the MySQL database engine.
**/
const unbuf = require('buf').unbuf.hex;
const ScopeSet = require('../../../../fxa-shared').oauth.scopes;
module.exports = {
/**
* This helper takes a list of active oauth tokens and produces an aggregate
* summary of the active clients, by:
*
* * merging all scopes into a single set
* * taking the max token creation time a the last access time
*
* The resulting array is in sorted order by last access time, then client name.
*
* @param {Array} activeClientTokens
* An array of OAuth tokens, annotated with client name:
* (OAuth client) name|(OAuth client) id|(Token) createdAt|(Token) scope
* @returns {Array}
*/
aggregateActiveClients: function aggregateActiveClients(activeClientTokens) {
if (!activeClientTokens) {
return [];
}
// Create an Object that stores unique OAuth client data
var activeClients = {};
activeClientTokens.forEach(function(clientTokenObj) {
var clientIdHex = unbuf(clientTokenObj.id);
if (!activeClients[clientIdHex]) {
// add the OAuth client if not already in the Object
activeClients[clientIdHex] = {
id: clientTokenObj.id,
name: clientTokenObj.name,
lastAccessTime: clientTokenObj.createdAt,
scope: ScopeSet.fromArray([]),
};
}
activeClients[clientIdHex].scope.add(clientTokenObj.scope);
var clientTokenTime =
clientTokenObj.lastUsedAt || clientTokenObj.createdAt;
if (clientTokenTime > activeClients[clientIdHex].lastAccessTime) {
// only update the createdAt if it is newer
activeClients[clientIdHex].lastAccessTime = clientTokenTime;
}
});
// Sort the scopes alphabetically, convert the Object structure to an array
var activeClientsArray = Object.keys(activeClients).map(function(key) {
activeClients[key].scope = activeClients[key].scope
.getScopeValues()
.sort();
return activeClients[key];
});
// Sort the final Array structure first by lastAccessTime and then name
activeClientsArray.sort(function(a, b) {
if (b.lastAccessTime > a.lastAccessTime) {
return 1;
}
if (b.lastAccessTime < a.lastAccessTime) {
return -1;
}
if (a.name > b.name) {
return 1;
}
if (a.name < b.name) {
return -1;
}
return 0;
});
return activeClientsArray;
},
};

Просмотреть файл

@ -0,0 +1,205 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const unbuf = require('buf').unbuf.hex;
const P = require('../../promise');
const config = require('../../../config');
const encrypt = require('../encrypt');
const logger = require('../logging')('db');
const mysql = require('./mysql');
function clientEquals(configClient, dbClient) {
var props = Object.keys(configClient);
for (var i = 0; i < props.length; i++) {
var prop = props[i];
var configProp = unbuf(configClient[prop]);
var dbProp = unbuf(dbClient[prop]);
if (configProp !== dbProp) {
logger.debug('clients.differ', {
prop: prop,
configProp: configProp,
dbProp: dbProp,
});
return false;
}
}
return true;
}
function convertClientToConfigFormat(client) {
var out = {};
for (var key in client) {
if (key === 'hashedSecret' || key === 'hashedSecretPrevious') {
out[key] = unbuf(client[key]);
} else if (key === 'trusted' || key === 'canGrant') {
out[key] = !!client[key]; // db stores booleans as 0 or 1.
} else if (typeof client[key] !== 'function') {
out[key] = unbuf(client[key]);
}
}
return out;
}
function preClients() {
var clients = config.get('oauthServer.clients');
if (clients && clients.length) {
logger.debug('predefined.loading', { clients: clients });
return P.all(
clients.map(function(c) {
if (c.secret) {
// eslint-disable-next-line no-console
console.error(
'Do not keep client secrets in the config file.' + // eslint-disable-line no-console
' Use the `hashedSecret` field instead.\n\n' +
'\tclient=%s has `secret` field\n' +
'\tuse hashedSecret="%s" instead',
c.id,
unbuf(encrypt.hash(c.secret))
);
return P.reject(
new Error('Do not keep client secrets in the config file.')
);
}
// ensure the required keys are present.
var err = null;
var REQUIRED_CLIENTS_KEYS = [
'id',
'hashedSecret',
'name',
'imageUri',
'redirectUri',
'trusted',
'canGrant',
];
REQUIRED_CLIENTS_KEYS.forEach(function(key) {
if (!(key in c)) {
var data = { key: key, name: c.name || 'unknown' };
logger.error('client.missing.keys', data);
err = new Error('Client config has missing keys');
}
});
if (err) {
return P.reject(err);
}
// ensure booleans are boolean and not undefined
c.trusted = !!c.trusted;
c.canGrant = !!c.canGrant;
c.publicClient = !!c.publicClient;
// Modification of the database at startup in production and stage is
// not preferred. This option will be set to false on those stacks.
if (!config.get('oauthServer.db.autoUpdateClients')) {
return P.resolve();
}
return exports.getClient(c.id).then(function(client) {
if (client) {
client = convertClientToConfigFormat(client);
logger.info('client.compare', { id: c.id });
if (clientEquals(client, c)) {
logger.info('client.compare.equal', { id: c.id });
} else {
logger.warn('client.compare.differs', {
id: c.id,
before: client,
after: c,
});
return exports.updateClient(c);
}
} else {
return exports.registerClient(c);
}
});
})
);
} else {
return P.resolve();
}
}
/**
* Insert pre-defined list of scopes into the DB
*/
function scopes() {
var scopes = config.get('oauthServer.scopes');
if (scopes && scopes.length) {
logger.debug('scopes.loading', JSON.stringify(scopes));
return P.all(
scopes.map(function(s) {
return exports.getScope(s.scope).then(function(existing) {
if (existing) {
logger.verbose('scopes.existing', s);
return;
}
return exports.registerScope(s);
});
})
);
}
}
var driver;
function withDriver() {
if (driver) {
return P.resolve(driver);
}
const p = mysql.connect(config.get('oauthServer.mysql'));
return p
.then(function(store) {
logger.debug('connected', {
driver: 'mysql',
});
driver = store;
})
.then(exports._initialClients)
.then(function() {
return driver;
});
}
const proxyReturn = logger.isEnabledFor(logger.VERBOSE)
? function verboseReturn(promise, method) {
return promise.then(function(ret) {
logger.verbose('proxied', { method: method, ret: ret });
return ret;
});
}
: function identity(x) {
return x;
};
function proxy(method) {
return function proxied() {
var args = arguments;
return withDriver()
.then(function(driver) {
logger.verbose('proxying', { method: method, args: args });
return proxyReturn(driver[method].apply(driver, args), method);
})
.catch(function(err) {
logger.error(method, err);
throw err;
});
};
}
Object.keys(mysql.prototype).forEach(function(key) {
exports[key] = proxy(key);
});
exports.disconnect = function disconnect() {
driver = null;
};
exports._initialClients = function() {
return preClients().then(scopes);
};

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -0,0 +1,9 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// The expected patch level of the database.
// Update this if you add a new patch, and don't forget to update
// the documentation for the current schema in ../schema.sql.
module.exports.level = 28;

Просмотреть файл

@ -0,0 +1,9 @@
-- Create the 'dbMetadata' table.
-- Note: This should be the only thing in this initial patch.
CREATE TABLE dbMetadata (
name VARCHAR(255) NOT NULL PRIMARY KEY,
value VARCHAR(255) NOT NULL
) ENGINE=InnoDB;
INSERT INTO dbMetadata SET name = 'schema-patch-level', value = '1';

Просмотреть файл

@ -0,0 +1,2 @@
-- -- drop the dbMetadata table
-- DROP TABLE dbMetadata;

Просмотреть файл

@ -0,0 +1,45 @@
-- Create the initial set of tables.
--
-- Since this is the first migration, we use `IF NOT EXISTS` to allow us
-- to run this on a db that already has the original schema in place. The
-- patch will then be a no-op. Subsequent patches should *not* use `IF
-- NOT EXISTS` but should fail noisily if the db is in an unexpected state.
CREATE TABLE IF NOT EXISTS clients (
id BINARY(8) PRIMARY KEY,
secret BINARY(32) NOT NULL,
name VARCHAR(256) NOT NULL,
imageUri VARCHAR(256) NOT NULL,
redirectUri VARCHAR(256) NOT NULL,
whitelisted BOOLEAN DEFAULT FALSE,
canGrant BOOLEAN DEFAULT FALSE,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
CREATE TABLE IF NOT EXISTS codes (
code BINARY(32) PRIMARY KEY,
clientId BINARY(8) NOT NULL,
INDEX codes_client_id(clientId),
FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
userId BINARY(16) NOT NULL,
INDEX codes_user_id(userId),
email VARCHAR(256) NOT NULL,
scope VARCHAR(256) NOT NULL,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
CREATE TABLE IF NOT EXISTS tokens (
token BINARY(32) PRIMARY KEY,
clientId BINARY(8) NOT NULL,
INDEX tokens_client_id(clientId),
FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
userId BINARY(16) NOT NULL,
INDEX tokens_user_id(userId),
email VARCHAR(256) NOT NULL,
type VARCHAR(16) NOT NULL,
scope VARCHAR(256) NOT NULL,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
UPDATE dbMetadata SET value = '2' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,9 @@
-- Drop all the tables.
-- (commented out to avoid accidentally running this in production...)
-- DROP TABLE clients;
-- DROP TABLE codes;
-- DROP TABLE tokens;
-- UPDATE dbMetadata SET value = '1' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,6 @@
-- Add `authAt` column to the `codes` table.
ALTER TABLE codes ADD COLUMN authAt BIGINT DEFAULT 0;
UPDATE dbMetadata SET value = '3' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,7 @@
-- Remove `authAt` column from the `codes` table.
-- (commented out to avoid accidentally running this in production...)
-- ALTER TABLE codes DROP COLUMN authAt;
-- UPDATE dbMetadata SET value = '2' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,19 @@
-- Adds support for Client Developers for OAuth clients
CREATE TABLE developers (
developerId BINARY(16) NOT NULL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(email)
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
CREATE TABLE clientDevelopers (
rowId BINARY(8) NOT NULL PRIMARY KEY,
developerId BINARY(16) NOT NULL,
FOREIGN KEY (developerId) REFERENCES developers(developerId) ON DELETE CASCADE,
clientId BINARY(8) NOT NULL,
FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
UPDATE dbMetadata SET value = '4' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,6 @@
-- Remove support for Client Developers for OAuth clients
-- DROP TABLE clientDevelopers;
-- DROP TABLE developers;
-- UPDATE dbMetadata SET value = '3' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,13 @@
-- Begins process of renaming "whitelisted" to "trusted".
-- We need to drop the "whitelisted" column in a separate patch in order
-- to safely deploy this without downtime.
ALTER TABLE clients ADD COLUMN trusted BOOLEAN DEFAULT FALSE;
UPDATE clients SET trusted=whitelisted;
-- Adds new "termsUri" and "privacyUri" columns for third-party clients.
ALTER TABLE clients ADD COLUMN termsUri VARCHAR(256) NOT NULL AFTER redirectUri;
ALTER TABLE clients ADD COLUMN privacyUri VARCHAR(256) NOT NULL AFTER termsUri;
UPDATE dbMetadata SET value = '5' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,9 @@
-- Remove "termsUri" and "privacyUri" columns".
-- Remove "trusted" column, ensuring to sync with old "whitelist" column.
-- ALTER TABLE clients DROP COLUMN privacyUri;
-- ALTER TABLE clients DROP COLUMN termsUri;
-- UPDATE clients SET whitelisted=trusted;
-- ALTER TABLE clients DROP COLUMN trusted;
-- UPDATE dbMetadata SET value = '4' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,24 @@
-- Add refreshTokens
CREATE TABLE refreshTokens (
token BINARY(32) PRIMARY KEY,
clientId BINARY(8) NOT NULL,
INDEX tokens_client_id(clientId),
FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
userId BINARY(16) NOT NULL,
INDEX tokens_user_id(userId),
email VARCHAR(256) NOT NULL,
scope VARCHAR(256) NOT NULL,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
-- Add expiresAt column for access tokens
ALTER TABLE tokens ADD COLUMN expiresAt TIMESTAMP NOT NULL DEFAULT "1980-01-01 00:00:00";
UPDATE tokens SET expiresAt = DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 2 WEEK);
-- Add offline column to codes
ALTER TABLE codes ADD COLUMN offline BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE dbMetadata SET value = '6' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,8 @@
-- Remove refreshToken table and expiresAt column from tokens table.
-- (commented out to avoid accidentally running this in production...)
-- DROP TABLE refreshTokens;
-- ALTER TABLE tokens DROP COLUMN expiresAt;
-- ALTER TABLE codes DROP COLUMN offline;
-- UPDATE dbMetadata SET value = '5' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,7 @@
-- Change clients.secret to clients.hashedSecret
-- Drop whitelisted column
ALTER TABLE clients CHANGE secret hashedSecret BINARY(32);
ALTER TABLE clients DROP COLUMN whitelisted;
UPDATE dbMetadata SET value = '7' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,8 @@
-- Change clients.hashedSecret to clients.secret
-- (commented out to avoid accidentally running this in production...)
-- ALTER TABLE clients CHANGE hashedSecret secret BINARY(32);
-- ALTER TABLE clients ADD COLUMN whitelisted BOOLEAN DEFAULT FALSE;
-- UPDATE clients SET whitelisted=trusted;
-- UPDATE dbMetadata SET value = '6' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,5 @@
ALTER TABLE clients CHANGE hashedSecret secret BINARY(32);
ALTER TABLE clients ADD COLUMN whitelisted BOOLEAN DEFAULT FALSE;
UPDATE clients SET whitelisted=trusted;
UPDATE dbMetadata SET value = '8' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,8 @@
-- Change clients.secret to clients.hashedSecret
-- Drop whitelisted column
-- (commented out to avoid accidentally running this in production...)
-- ALTER TABLE clients CHANGE secret hashedSecret BINARY(32);
-- ALTER TABLE clients DROP COLUMN whitelisted;
-- UPDATE dbMetadata SET value = '7' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,6 @@
-- Add hashedSecret column, to replace secret column.
ALTER TABLE clients ADD COLUMN hashedSecret BINARY(32);
UPDATE clients SET hashedSecret = secret;
UPDATE dbMetadata SET value = '9' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,4 @@
-- (commented out to avoid accidentally running this in production...)
-- ALTER TABLE clients DROP COLUMN hashedSecret;
-- UPDATE dbMetadata SET value = '8' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,5 @@
-- Remove `secret` column.
ALTER TABLE clients DROP COLUMN secret;
UPDATE dbMetadata SET value = '10' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,6 @@
-- (commented out to avoid accidentally running this in production...)
-- ALTER TABLE clients ADD COLUMN secret BINARY(32);
-- UPDATE clients SET secret = hashedSecret;
-- UPDATE dbMetadata SET value = '9' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,5 @@
-- dropping NOT NULL constraint
ALTER TABLE tokens MODIFY COLUMN email VARCHAR(256);
UPDATE dbMetadata SET value = '11' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,5 @@
-- (commented out to avoid accidentally running this in production...)
-- ALTER TABLE tokens MODIFY COLUMN email VARCHAR(256) NOT NULL;
-- UPDATE dbMetadata SET value = '10' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,5 @@
-- Drop whitelisted column
ALTER TABLE clients DROP COLUMN whitelisted;
UPDATE dbMetadata SET value = '12' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,6 @@
-- (commented out to avoid accidentally running this in production...)
-- ALTER TABLE clients ADD COLUMN whitelisted BOOLEAN DEFAULT FALSE;
-- UPDATE clients SET whitelisted=trusted;
-- UPDATE dbMetadata SET value = '11' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,7 @@
-- Re-create NOT NULL constraint on the email column.
-- We weren't able to drop it in production, this migration
-- brings it back in our dev environments.
ALTER TABLE tokens MODIFY COLUMN email VARCHAR(256) NOT NULL;
UPDATE dbMetadata SET value = '13' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,5 @@
-- (commented out to avoid accidentally running this in production...)
-- ALTER TABLE tokens MODIFY COLUMN email VARCHAR(256);
-- UPDATE dbMetadata SET value = '12' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,5 @@
-- Add hashedSecretPrevious column
ALTER TABLE clients ADD COLUMN hashedSecretPrevious BINARY(32);
UPDATE dbMetadata SET value = '14' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,5 @@
-- (commented out to avoid accidentally running this in production...)
-- ALTER TABLE clients DROP COLUMN hashedSecretPrevious;
-- UPDATE dbMetadata SET value = '13' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,5 @@
-- Add index idx_expiresAt to token table
ALTER TABLE tokens ADD INDEX idx_expiresAt (expiresAt), ALGORITHM = INPLACE, LOCK = NONE;
UPDATE dbMetadata SET value = '15' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,5 @@
-- (commented out to avoid accidentally running this in production...)
-- ALTER TABLE tokens DROP INDEX idx_expiresAt;
-- UPDATE dbMetadata SET value = '14' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,6 @@
-- Remove "termsUri" and "privacyUri" columns".
ALTER TABLE clients DROP COLUMN privacyUri;
ALTER TABLE clients DROP COLUMN termsUri;
UPDATE dbMetadata SET value = '16' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,6 @@
-- Adds new "termsUri" and "privacyUri" columns for third-party clients.
--ALTER TABLE clients ADD COLUMN termsUri VARCHAR(256) NOT NULL AFTER redirectUri;
--ALTER TABLE clients ADD COLUMN privacyUri VARCHAR(256) NOT NULL AFTER termsUri;
--UPDATE dbMetadata SET value = '15' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,6 @@
-- Add `lastUsedAt` column to the `refreshTokens` table.
ALTER TABLE refreshTokens ADD COLUMN lastUsedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
ALGORITHM = INPLACE, LOCK = NONE;
UPDATE dbMetadata SET value = '17' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,6 @@
-- Drop `lastUsedAt` column to the `refreshTokens` table.
-- ALTER TABLE refreshTokens DROP COLUMN lastUsedAt,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- UPDATE dbMetadata SET value = '16' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,11 @@
-- Add `publicClient` column to the `clients` table.
ALTER TABLE clients ADD COLUMN publicClient BOOLEAN DEFAULT FALSE NOT NULL AFTER canGrant;
UPDATE clients SET publicClient=false;
-- Add `codeChallengeMethod` and `codeChallenge` column to the `codes` table.
ALTER TABLE codes
ADD COLUMN codeChallengeMethod VARCHAR(256) AFTER offline,
ADD COLUMN codeChallenge VARCHAR(256) AFTER codeChallengeMethod,
ALGORITHM = INPLACE, LOCK = NONE;
UPDATE dbMetadata SET value = '18' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,14 @@
-- Drop `publicClient` column from the `clients` table.
-- ALTER TABLE clients DROP COLUMN publicClient,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- Drop `codeChallengeMethod` and `codeChallenge` column from the `codes` table.
-- ALTER TABLE codes DROP COLUMN codeChallengeMethod,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- ALTER TABLE codes DROP COLUMN codeChallenge,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- UPDATE dbMetadata SET value = '17' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,4 @@
ALTER TABLE codes ADD COLUMN keysJwe MEDIUMTEXT,
ALGORITHM = INPLACE, LOCK = NONE;
UPDATE dbMetadata SET value = '19' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,4 @@
-- ALTER TABLE codes DROP COLUMN keysJwe,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- UPDATE dbMetadata SET value = '18' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,10 @@
ALTER TABLE clients
ADD COLUMN allowedScopes VARCHAR(1024) AFTER trusted,
ALGORITHM = INPLACE, LOCK = NONE;
CREATE TABLE scopes (
scope VARCHAR(128) NOT NULL PRIMARY KEY,
hasScopedKeys BOOLEAN NOT NULL DEFAULT FALSE
) ENGINE=InnoDB;
UPDATE dbMetadata SET value = '20' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,6 @@
-- ALTER TABLE clients DROP COLUMN allowedScopes,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- DROP TABLE scopes;
-- UPDATE dbMetadata SET value = '19' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,14 @@
-- Add columns to stash 'amr' and 'aal' on the codes table.
-- The 'amr' column will be a comma-separated string.
-- It's tempting to use MySQL's SET datatype to save on storage space here:
--
-- https://dev.mysql.com/doc/refman/5.7/en/set.html
--
-- But codes are transient, so it doesn't seem worthwhile to
-- trade the maintenance complexity for a little storage space.
ALTER TABLE codes
ADD COLUMN amr VARCHAR(128) AFTER authAt,
ADD COLUMN aal TINYINT AFTER amr,
ALGORITHM = INPLACE, LOCK = NONE;
UPDATE dbMetadata SET value = '21' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,6 @@
-- ALTER TABLE codes
-- DROP COLUMN amr,
-- DROP COLUMN aal,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- UPDATE dbMetadata SET value = '20' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,11 @@
-- Add column to stash the `profileChangedAt` value
ALTER TABLE codes ADD COLUMN profileChangedAt BIGINT DEFAULT NULL,
ALGORITHM = INPLACE, LOCK = NONE;
ALTER TABLE tokens ADD COLUMN profileChangedAt BIGINT DEFAULT NULL,
ALGORITHM = INPLACE, LOCK = NONE;
ALTER TABLE refreshTokens ADD COLUMN profileChangedAt BIGINT DEFAULT NULL,
ALGORITHM = INPLACE, LOCK = NONE;
UPDATE dbMetadata SET value = '22' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,13 @@
-- ALTER TABLE tokens
-- DROP COLUMN profileChangedAt,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- ALTER TABLE codes
-- DROP COLUMN profileChangedAt,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- ALTER TABLE refreshTokens
-- DROP COLUMN profileChangedAt,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- UPDATE dbMetadata SET value = '21`' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,55 @@
-- Drop foreign key constraints. They make DB migrations harder
-- and aren't really providing us much value in practice.
-- The `clientDevelopers` table needs indexes on `developerId` a `clientId`
-- for fast lookup. Prior to this patch, we were taking advantage of the
-- index that is automatically created to enforce foreign key constraints,
-- which the MySQL docs at [1] describe as:
--
-- """
-- In the referencing table, there must be an index where the foreign key
-- columns are listed as the first columns in the same order. Such an index
-- is created on the referencing table automatically if it does not exist.
-- This index might be silently dropped later, if you create another index
-- that can be used to enforce the foreign key constraint.
-- """
-- [1] https://dev.mysql.com/doc/refman/5.7/en/create-table-foreign-keys.html
--
-- The "might" in there leaves some doubt about the exact circumstances under
-- which we can depend on this index continuing to exist, so this migration
-- explicitly creates the indexes we need. It's a two step process:
--
-- 1) Explicitly create the indexes we need. This "might" cause the ones
-- that were created automatically for the FK constraint to be dropped.
--
-- 2) Drop the FK constraints, which might leave behind the auto-created
-- indexes if they weren't dropped in (1) above.
--
-- In my testing, the auto-created indexes are indeed dropped in favour
-- of the explicit ones. If they aren't, then at least we wind up with
-- duplicate indexes which can be cleaned up manually, which is much better
-- than winding up with no indexes at all.
--
ALTER TABLE clientDevelopers ADD INDEX idx_clientDevelopers_developerId(developerId),
ALGORITHM = INPLACE, LOCK = NONE;
ALTER TABLE clientDevelopers ADD INDEX idx_clientDevelopers_clientId(clientId),
ALGORITHM = INPLACE, LOCK = NONE;
ALTER TABLE clientDevelopers DROP FOREIGN KEY clientDevelopers_ibfk_1,
ALGORITHM = INPLACE, LOCK = NONE;
ALTER TABLE clientDevelopers DROP FOREIGN KEY clientDevelopers_ibfk_2,
ALGORITHM = INPLACE, LOCK = NONE;
ALTER TABLE refreshTokens DROP FOREIGN KEY refreshTokens_ibfk_1,
ALGORITHM = INPLACE, LOCK = NONE;
ALTER TABLE codes DROP FOREIGN KEY codes_ibfk_1,
ALGORITHM = INPLACE, LOCK = NONE;
ALTER TABLE tokens DROP FOREIGN KEY tokens_ibfk_1,
ALGORITHM = INPLACE, LOCK = NONE;
UPDATE dbMetadata SET value = '23' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,23 @@
-- ALTER TABLE clientDevelopers DROP INDEX idx_clientDevelopers_developerId,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- ALTER TABLE clientDevelopers DROP INDEX idx_clientDevelopers_clientId(clientId),
-- ALGORITHM = INPLACE, LOCK = NONE;
-- ALTER TABLE clientDevelopers ADD FOREIGN KEY (developerId) REFERENCES developers(developerId) ON DELETE CASCADE,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- ALTER TABLE clientDevelopers ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- ALTER TABLE refreshTokens ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- ALTER TABLE codes ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- ALTER TABLE tokens ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- UPDATE dbMetadata SET value = '22' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,8 @@
-- This removes the `profileChangedAt` column on the tokens table.
-- Since the tokens table is so large, this migration causes some issues.
-- When the tokens get purged and the table is a bit smaller we can attempt to
-- add this column.
ALTER TABLE tokens DROP COLUMN profileChangedAt,
ALGORITHM = INPLACE, LOCK = NONE;
UPDATE dbMetadata SET value = '24' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,4 @@
-- ALTER TABLE tokens ADD COLUMN profileChangedAt BIGINT DEFAULT NULL,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- UPDATE dbMetadata SET value = '23`' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,26 @@
-- This restores some of the FOREIGN KEY constraints that were
-- dropped in migration #23. They unexpectedly caused MySQL to
-- change its query plan and start scanning large chunks of the
-- `tokens` table. More context here:
--
-- https://github.com/mozilla/fxa-auth-server/issues/2695
--
-- We'll remove them again in a follow-up migration, and adjust
-- the queries to ensure they use the right indexes. For now
-- we're just putting them back so that the alleged state of
-- the db matches what's in production.
SET FOREIGN_KEY_CHECKS=0;
ALTER TABLE refreshTokens ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
ALGORITHM = INPLACE, LOCK = NONE;
ALTER TABLE codes ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
ALGORITHM = INPLACE, LOCK = NONE;
ALTER TABLE tokens ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
ALGORITHM = INPLACE, LOCK = NONE;
SET FOREIGN_KEY_CHECKS=1;
UPDATE dbMetadata SET value = '25' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,11 @@
-- ALTER TABLE refreshTokens DROP FOREIGN KEY refreshTokens_ibfk_1,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- ALTER TABLE codes DROP FOREIGN KEY codes_ibfk_1,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- ALTER TABLE tokens DROP FOREIGN KEY tokens_ibfk_1,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- UPDATE dbMetadata SET value = '24' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,18 @@
-- Drop foreign key constraints. They make DB migrations harder
-- and aren't really providing us much value in practice.
--
-- We previously attempted this in migration #23, but had to roll
-- it back because it caused MySQL's query planner to make some poor
-- choices. We've re-worked the queries to help it make better
-- choices and are now ready to try again.
ALTER TABLE refreshTokens DROP FOREIGN KEY refreshTokens_ibfk_1,
ALGORITHM = INPLACE, LOCK = NONE;
ALTER TABLE codes DROP FOREIGN KEY codes_ibfk_1,
ALGORITHM = INPLACE, LOCK = NONE;
ALTER TABLE tokens DROP FOREIGN KEY tokens_ibfk_1,
ALGORITHM = INPLACE, LOCK = NONE;
UPDATE dbMetadata SET value = '26' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,11 @@
-- ALTER TABLE refreshTokens ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- ALTER TABLE codes ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- ALTER TABLE tokens ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- UPDATE dbMetadata SET value = '25' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,7 @@
-- The `profileChangedAt` column was removed in patch-023-024 because
-- it caused migration issues. Now that we have resolved our migration
-- issues, it is safe to add this column back.
ALTER TABLE tokens ADD COLUMN profileChangedAt BIGINT DEFAULT NULL,
ALGORITHM = INPLACE, LOCK = NONE;
UPDATE dbMetadata SET value = '27' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,4 @@
-- ALTER TABLE tokens DROP COLUMN profileChangedAt,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- UPDATE dbMetadata SET value = '26' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,4 @@
ALTER TABLE codes ADD COLUMN sessionTokenId BINARY(32) DEFAULT NULL,
ALGORITHM = INPLACE, LOCK = NONE;
UPDATE dbMetadata SET value = '28' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,4 @@
-- ALTER TABLE codes DROP COLUMN sessionTokenId,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- UPDATE dbMetadata SET value = '27' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,84 @@
--
-- This file represents the current db schema.
-- It exists mainly for documentation purposes; any automated database
-- modifications are controlled by the files in the ./patches/ directory.
--
-- If you make a change here, you should also create a new database patch
-- file and increment the level in ./patch.js to reflect the change.
--
CREATE TABLE IF NOT EXISTS clients (
id BINARY(8) PRIMARY KEY,
hashedSecret BINARY(32),
hashedSecretPrevious BINARY(32),
name VARCHAR(256) NOT NULL,
imageUri VARCHAR(256) NOT NULL,
redirectUri VARCHAR(256) NOT NULL,
canGrant BOOLEAN DEFAULT FALSE,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
trusted BOOLEAN DEFAULT FALSE,
allowedScopes VARCHAR(1024)
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
CREATE TABLE IF NOT EXISTS codes (
code BINARY(32) PRIMARY KEY,
clientId BINARY(8) NOT NULL,
INDEX codes_client_id(clientId),
userId BINARY(16) NOT NULL,
INDEX codes_user_id(userId),
email VARCHAR(256) NOT NULL,
scope VARCHAR(256) NOT NULL,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
authAt BIGINT DEFAULT 0,
offline BOOLEAN DEFAULT FALSE,
profileChangedAt BIGINT DEFAULT NULL
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
CREATE TABLE IF NOT EXISTS tokens (
token BINARY(32) PRIMARY KEY,
clientId BINARY(8) NOT NULL,
INDEX tokens_client_id(clientId),
userId BINARY(16) NOT NULL,
INDEX tokens_user_id(userId),
email VARCHAR(256) NOT NULL,
type VARCHAR(16) NOT NULL,
scope VARCHAR(256) NOT NULL,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expiresAt TIMESTAMP NOT NULL,
profileChangedAt BIGINT DEFAULT NULL,
INDEX idx_expiresAt(expiresAt)
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
CREATE TABLE IF NOT EXISTS developers (
developerId BINARY(16) NOT NULL,
email VARCHAR(255) NOT NULL,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(email)
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
CREATE TABLE IF NOT EXISTS clientDevelopers (
rowId BINARY(8) PRIMARY KEY,
developerId BINARY(16) NOT NULL,
clientId BINARY(8) NOT NULL,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_clientDevelopers_developerId(developerId),
INDEX idx_clientDevelopers_clientId(clientId)
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
CREATE TABLE IF NOT EXISTS refreshTokens (
token BINARY(32) PRIMARY KEY,
clientId BINARY(8) NOT NULL,
INDEX tokens_client_id(clientId),
userId BINARY(16) NOT NULL,
INDEX tokens_user_id(userId),
email VARCHAR(256) NOT NULL,
scope VARCHAR(256) NOT NULL,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
lastUsedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
profileChangedAt BIGINT DEFAULT NULL
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
CREATE TABLE scopes (
scope VARCHAR(128) NOT NULL PRIMARY KEY,
hasScopedKeys BOOLEAN NOT NULL DEFAULT FALSE
) ENGINE=InnoDB;

Просмотреть файл

@ -0,0 +1,12 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const crypto = require('crypto');
const buf = require('buf').hex;
exports.hash = function hash(value) {
var sha = crypto.createHash('sha256');
sha.update(buf(value));
return sha.digest();
};

Просмотреть файл

@ -0,0 +1,378 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const util = require('util');
const hex = require('buf').to.hex;
const DEFAULTS = {
code: 500,
error: 'Internal Server Error',
errno: 999,
info:
'https://github.com/mozilla/fxa-oauth-server/blob/master/docs/api.md#errors',
message: 'Unspecified error',
};
function merge(target, other) {
var keys = Object.keys(other || {});
for (var i = 0; i < keys.length; i++) {
target[keys[i]] = other[keys[i]];
}
}
function AppError(options, extra, headers) {
this.message = options.message || DEFAULTS.message;
this.isBoom = true;
if (options.stack) {
this.stack = options.stack;
} else {
Error.captureStackTrace(this, AppError);
}
this.errno = options.errno || DEFAULTS.errno;
this.output = {
statusCode: options.code || DEFAULTS.code,
payload: {
code: options.code || DEFAULTS.code,
errno: this.errno,
error: options.error || DEFAULTS.error,
message: this.message,
info: options.info || DEFAULTS.info,
},
headers: headers || {},
};
merge(this.output.payload, extra);
}
util.inherits(AppError, Error);
AppError.prototype.toString = function() {
return 'Error: ' + this.message;
};
AppError.prototype.header = function(name, value) {
this.output.headers[name] = value;
};
AppError.isOauthRoute = function isOauthRoute(path) {
const routes = require('./routes').routes;
return routes.findIndex(r => r.path === path) > -1;
};
AppError.translate = function translate(response) {
if (response instanceof AppError) {
return response;
}
var error;
var payload = response.output.payload;
if (payload.validation) {
error = AppError.invalidRequestParameter(payload.validation);
} else if (payload.statusCode === 415) {
error = AppError.invalidContentType();
} else {
error = new AppError({
message: payload.message,
code: payload.statusCode,
error: payload.error,
errno: payload.errno,
stack: response.stack,
});
}
return error;
};
AppError.prototype.backtrace = function(traced) {
this.output.payload.log = traced;
};
AppError.unexpectedError = function unexpectedError() {
return new AppError({});
};
AppError.unknownClient = function unknownClient(clientId) {
return new AppError(
{
code: 400,
error: 'Bad Request',
errno: 101,
message: 'Unknown client',
},
{
clientId: hex(clientId),
}
);
};
AppError.incorrectSecret = function incorrectSecret(clientId) {
return new AppError(
{
code: 400,
error: 'Bad Request',
errno: 102,
message: 'Incorrect secret',
},
{
clientId: hex(clientId),
}
);
};
AppError.incorrectRedirect = function incorrectRedirect(uri) {
return new AppError(
{
code: 400,
error: 'Bad Request',
errno: 103,
message: 'Incorrect redirect_uri',
},
{
redirectUri: uri,
}
);
};
AppError.invalidAssertion = function invalidAssertion() {
return new AppError({
code: 401,
error: 'Bad Request',
errno: 104,
message: 'Invalid assertion',
});
};
AppError.unknownCode = function unknownCode(code) {
return new AppError(
{
code: 400,
error: 'Bad Request',
errno: 105,
message: 'Unknown code',
},
{
requestCode: code,
}
);
};
AppError.mismatchCode = function mismatchCode(code, clientId) {
return new AppError(
{
code: 400,
error: 'Bad Request',
errno: 106,
message: 'Incorrect code',
},
{
requestCode: hex(code),
client: hex(clientId),
}
);
};
AppError.expiredCode = function expiredCode(code, expiredAt) {
return new AppError(
{
code: 400,
error: 'Bad Request',
errno: 107,
message: 'Expired code',
},
{
requestCode: hex(code),
expiredAt: expiredAt,
}
);
};
AppError.invalidToken = function invalidToken() {
return new AppError({
code: 400,
error: 'Bad Request',
errno: 108,
message: 'Invalid token',
});
};
AppError.invalidRequestParameter = function invalidRequestParameter(val) {
return new AppError(
{
code: 400,
error: 'Bad Request',
errno: 109,
message: 'Invalid request parameter',
},
{
validation: val,
}
);
};
AppError.invalidResponseType = function invalidResponseType() {
return new AppError({
code: 400,
error: 'Bad Request',
errno: 110,
message: 'Invalid response_type',
});
};
AppError.unauthorized = function unauthorized(reason) {
return new AppError(
{
code: 401,
error: 'Unauthorized',
errno: 111,
message: 'Unauthorized for route',
},
{
detail: reason,
}
);
};
AppError.forbidden = function forbidden() {
return new AppError({
code: 403,
error: 'Forbidden',
errno: 112,
message: 'Forbidden',
});
};
AppError.invalidContentType = function invalidContentType() {
return new AppError({
code: 415,
error: 'Unsupported Media Type',
errno: 113,
message:
'Content-Type must be either application/json or ' +
'application/x-www-form-urlencoded',
});
};
AppError.invalidScopes = function invalidScopes(scopes) {
return new AppError(
{
code: 400,
error: 'Invalid scopes',
errno: 114,
message: 'Requested scopes are not allowed',
},
{
invalidScopes: scopes,
}
);
};
AppError.expiredToken = function expiredToken(expiredAt) {
return new AppError(
{
code: 400,
error: 'Bad Request',
errno: 115,
message: 'Expired token',
},
{
expiredAt: expiredAt,
}
);
};
AppError.notPublicClient = function notPublicClient(clientId) {
return new AppError(
{
code: 400,
error: 'Bad Request',
errno: 116,
message: 'Not a public client',
},
{
clientId: hex(clientId),
}
);
};
AppError.mismatchCodeChallenge = function mismatchCodeChallenge(pkceHashValue) {
return new AppError(
{
code: 400,
error: 'Bad Request',
errno: 117,
message: 'Incorrect code_challenge',
},
{
requestCodeChallenge: pkceHashValue,
}
);
};
AppError.missingPkceParameters = function missingPkceParameters() {
return new AppError({
code: 400,
error: 'PKCE parameters missing',
errno: 118,
message: 'Public clients require PKCE OAuth parameters',
});
};
AppError.staleAuthAt = function staleAuthAt(authAt) {
return new AppError(
{
code: 401,
error: 'Bad Request',
errno: 119,
message: 'Stale authentication timestamp',
},
{
authAt: authAt,
}
);
};
AppError.mismatchAcr = function mismatchAcr(foundValue) {
return new AppError(
{
code: 400,
error: 'Bad Request',
errno: 120,
message: 'Mismatch acr value',
},
{ foundValue }
);
};
AppError.invalidGrantType = function invalidGrantType() {
return new AppError({
code: 400,
error: 'Bad Request',
errno: 121,
message: 'Invalid grant_type',
});
};
AppError.unknownToken = function unknownToken() {
return new AppError({
code: 400,
error: 'Bad Request',
errno: 122,
message: 'Unknown token',
});
};
// N.B. `errno: 201` is traditionally our generic "service unavailable" error,
// so let's reserve it for that purpose here as well.
AppError.disabledClient = function disabledClient(clientId) {
return new AppError(
{
code: 503,
error: 'Client Disabled',
errno: 202,
message: 'This client has been temporarily disabled',
},
{ clientId }
);
};
module.exports = AppError;

Просмотреть файл

@ -0,0 +1,258 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const buf = require('buf').hex;
const hex = require('buf').to.hex;
const config = require('../../config');
const AppError = require('./error');
const db = require('./db');
const util = require('./util');
const ScopeSet = require('../../../fxa-shared').oauth.scopes;
const JWTAccessToken = require('./jwt_access_token');
const logger = require('./logging')('grant');
const amplitude = require('./metrics/amplitude')(
logger,
config.getProperties()
);
const sub = require('./jwt_sub');
const subConfig = {
subscriptions: {
productCapabilities: config.get('subscriptions.productCapabilities'),
clientCapabilities: config.get('subscriptions.clientCapabilities'),
},
};
const {
determineClientVisibleSubscriptionCapabilities,
} = require('../routes/utils/subscriptions');
const ACR_VALUE_AAL2 = 'AAL2';
const ACCESS_TYPE_OFFLINE = 'offline';
const SCOPE_OPENID = ScopeSet.fromArray(['openid']);
const { OAUTH_SCOPE_SESSION_TOKEN } = require('../../lib/constants');
const ID_TOKEN_EXPIRATION = Math.floor(
config.get('oauthServer.openid.ttl') / 1000
);
const jwt = require('./jwt');
const JWT_ACCESS_TOKENS_ENABLED = config.get(
'oauthServer.jwtAccessTokens.enabled'
);
const JWT_ACCESS_TOKENS_CLIENT_IDS = new Set(
config.get('oauthServer.jwtAccessTokens.enabledClientIds')
);
const UNTRUSTED_CLIENT_ALLOWED_SCOPES = ScopeSet.fromArray([
'openid',
'profile:uid',
'profile:email',
'profile:display_name',
]);
let authdb = {};
module.exports.setDB = function(db) {
authdb = db;
};
// Given a set of verified user identity claims, can the given client
// be granted the specified access to the user's data?
//
// This is a shared helper function responsible for checking:
// * whether the identity claims are sufficient to authorize the requested access
// * whether config allows that particular client to request such access at all
//
// It does *not* perform any user or client authentication, assuming that the
// authenticity of the passed-in details has been sufficiently verified by
// calling code.
module.exports.validateRequestedGrant = async function validateRequestedGrant(
verifiedClaims,
client,
requestedGrant
) {
requestedGrant.scope = requestedGrant.scope || ScopeSet.fromArray([]);
// If the grant request is for specific ACR values, do the identity claims support them?
if (requestedGrant.acr_values) {
const acrTokens = requestedGrant.acr_values.trim().split(/\s+/g);
if (
acrTokens.includes(ACR_VALUE_AAL2) &&
!(verifiedClaims['fxa-aal'] >= 2)
) {
throw AppError.mismatchAcr(verifiedClaims['fxa-aal']);
}
}
// Is an untrusted client requesting scopes that it's not allowed?
if (!client.trusted) {
const invalidScopes = requestedGrant.scope.difference(
UNTRUSTED_CLIENT_ALLOWED_SCOPES
);
if (!invalidScopes.isEmpty()) {
throw AppError.invalidScopes(invalidScopes.getScopeValues());
}
}
// For custom scopes (besides the UNTRUSTED_CLIENT_ALLOWED_SCOPES list), is the client allowed to request them?
let requiresVerifiedToken = false;
const scopeConfig = {};
const customScopes = ScopeSet.fromArray([]);
for (const scope of requestedGrant.scope.getScopeValues()) {
const s = (scopeConfig[scope] = await db.getScope(scope));
if (s) {
if (s.hasScopedKeys) {
// scoped keys require verification, see comment below.
requiresVerifiedToken = true;
}
customScopes.add(scope);
}
}
if (!customScopes.isEmpty()) {
const invalidScopes = customScopes.difference(
ScopeSet.fromString(client.allowedScopes || '')
);
if (!invalidScopes.isEmpty()) {
throw AppError.invalidScopes(invalidScopes.getScopeValues());
}
}
if (requiresVerifiedToken && !verifiedClaims['fxa-tokenVerified']) {
// Any request for a key-bearing scope should be using a verified token,
// so we can also double-check that here as a defense-in-depth measure.
//
// Note that this directly reflects the `verified` property of the sessionToken
// used to create the assertion, so it can be true for e.g. sessions that were
// verified by email before 2FA was enabled on the account. Such sessions must
// be able to access sync even after 2FA is enabled, hence checking `verified`
// rather than the `aal`-related properties here.
throw AppError.invalidAssertion();
}
// If we grow our per-client config, there are more things we could check here:
// * Is this client allowed to request ACCESS_TYPE_OFFLINE?
// * Is this client allowed to request all the non-key-bearing scopes?
// * Do we expect this client to be using OIDC?
return {
clientId: client.id,
userId: buf(verifiedClaims.uid),
email: verifiedClaims['fxa-verifiedEmail'],
scope: requestedGrant.scope,
scopeConfig,
sessionTokenId: verifiedClaims['fxa-sessionTokenId'],
offline: requestedGrant.access_type === ACCESS_TYPE_OFFLINE,
authAt: verifiedClaims['fxa-lastAuthAt'],
amr: verifiedClaims['fxa-amr'],
aal: verifiedClaims['fxa-aal'],
profileChangedAt: verifiedClaims['fxa-profileChangedAt'],
keysJwe: requestedGrant.keys_jwe,
};
};
// Generate tokens that will give the holder all the access in the specified grant.
// This always include an access_token, but may also include a refresh_token and/or
// id_token if implied by the grant.
//
// This function does *not* perform any authentication or validation, assuming that
// the specified grant has been sufficiently vetted by calling code.
module.exports.generateTokens = async function generateTokens(grant) {
// We always generate an access_token.
const access = await exports.generateAccessToken(grant);
const result = {
access_token: access.jwt_token || access.token.toString('hex'),
token_type: access.type,
scope: access.scope.toString(),
};
result.expires_in =
grant.ttl || Math.floor((access.expiresAt - Date.now()) / 1000);
if (grant.authAt) {
result.auth_at = grant.authAt;
}
if (grant.keysJwe) {
result.keys_jwe = grant.keysJwe;
}
// Maybe also generate a refreshToken?
if (grant.offline) {
const refresh = await db.generateRefreshToken(grant);
result.refresh_token = refresh.token.toString('hex');
}
// Maybe also generate an idToken?
if (grant.scope && grant.scope.contains(SCOPE_OPENID)) {
result.id_token = await generateIdToken(grant, result.access_token);
}
if (grant.scope && grant.scope.contains(OAUTH_SCOPE_SESSION_TOKEN)) {
result.session_token_id =
grant.sessionTokenId && grant.sessionTokenId.toString('hex');
}
amplitude('token.created', {
service: hex(grant.clientId),
uid: hex(grant.userId),
});
return result;
};
async function generateIdToken(grant, accessToken) {
var now = Math.floor(Date.now() / 1000);
const clientId = hex(grant.clientId);
// The IETF spec for `aud` refers to https://openid.net/specs/openid-connect-core-1_0.html#IDToken
// > REQUIRED. Audience(s) that this ID Token is intended for. It MUST contain the
// > OAuth 2.0 client_id of the Relying Party as an audience value. It MAY also contain
// > identifiers for other audiences. In the general case, the aud value is an array of
// > case sensitive strings. In the common special case when there is one audience, the
// > aud value MAY be a single case sensitive string.
const audience = grant.resource ? [clientId, grant.resource] : clientId;
const claims = {
sub: await sub(grant.userId, grant.clientId, grant.ppidSeed),
aud: audience,
//iss set in jwt.sign
iat: now,
exp: now + ID_TOKEN_EXPIRATION,
at_hash: util.generateTokenHash(accessToken),
};
if (grant.amr) {
claims.amr = grant.amr;
}
if (grant.aal) {
claims['fxa-aal'] = grant.aal;
claims.acr = 'AAL' + grant.aal;
}
return jwt.sign(claims);
}
exports.generateAccessToken = async function generateAccessToken(grant) {
const accessToken = await db.generateAccessToken(grant);
if (
!JWT_ACCESS_TOKENS_ENABLED ||
!JWT_ACCESS_TOKENS_CLIENT_IDS.has(hex(grant.clientId).toLowerCase())
) {
// return the old style access token if JWT access tokens are
// not globally enabled or if not enabled for the given clientId.
return accessToken;
}
if (grant.scope.contains('profile:subscriptions')) {
const capabilities = await determineClientVisibleSubscriptionCapabilities(
subConfig,
{},
authdb,
hex(grant.userId),
grant.clientId
);
// To avoid mutating the input grant, create a
// copy and add the new property there.
grant = {
...grant,
'fxa-subscriptions': capabilities,
};
}
return JWTAccessToken.create(accessToken, grant);
};

Просмотреть файл

@ -0,0 +1,59 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const jsonwebtoken = require('jsonwebtoken');
const P = require('../promise');
const { publicPEM, SIGNING_PEM, SIGNING_KID, SIGNING_ALG } = require('./keys');
const config = require('../../config');
const ISSUER = config.get('oauthServer.openid.issuer');
const jwtverify = P.promisify(jsonwebtoken.verify);
/**
* Sign `claims` using SIGNING_PEM from keys.js, returning a JWT.
*
* @param {Object} claims
* @returns {Promise} resolves with signed JWT when complete
*/
exports.sign = function sign(claims, options) {
return jsonwebtoken.sign(
{
// force an issuer to be set for direct calls to .sign,
// it can be overridden in the passed in claims.
iss: ISSUER,
...claims,
},
SIGNING_PEM,
{
...options,
algorithm: SIGNING_ALG,
keyid: SIGNING_KID,
}
);
};
exports.verify = async function verify(jwt, options = {}) {
const getKey = (header, callback) => {
if (options.typ && header.typ !== options.typ) {
return callback(new Error('Invalid typ'));
}
let signingKey;
try {
signingKey = publicPEM(header.kid);
} catch (e) {
return callback(new Error('Invalid kid'));
}
callback(null, signingKey);
};
return jwtverify(jwt, getKey, {
algorithms: options.algorithms || [SIGNING_ALG],
json: true,
// use the default issuer unless one is passed in.
issuer: options.issuer || ISSUER,
});
};

Просмотреть файл

@ -0,0 +1,106 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const hex = require('buf').to.hex;
const AppError = require('./error');
const jwt = require('./jwt');
const sub = require('./jwt_sub');
const HEADER_TYP = 'at+JWT';
/**
* Create a JWT access token from `grant`
*/
exports.create = async function generateJWTAccessToken(accessToken, grant) {
const clientId = hex(grant.clientId);
// The IETF spec for `aud` refers to https://openid.net/specs/openid-connect-core-1_0.html#IDToken
// > REQUIRED. Audience(s) that this ID Token is intended for. It MUST contain the
// > OAuth 2.0 client_id of the Relying Party as an audience value. It MAY also contain
// > identifiers for other audiences. In the general case, the aud value is an array of
// > case sensitive strings. In the common special case when there is one audience, the
// > aud value MAY be a single case sensitive string.
const audience = grant.resource ? [clientId, grant.resource] : clientId;
// Claims list from:
// https://tools.ietf.org/html/draft-bertocci-oauth-access-token-jwt-00#section-2.2
const claims = {
aud: audience,
client_id: clientId,
exp: Math.floor(accessToken.expiresAt / 1000),
iat: Math.floor(Date.now() / 1000),
// iss is set in jwt.sign
jti: hex(accessToken.token),
scope: grant.scope.toString(),
sub: await sub(grant.userId, grant.clientId, grant.ppidSeed),
};
// Note, a new claim is used rather than scopes because
// FxA's scope checking somewhat blindly accepts user input,
// meaning a malicious user could reload FxA after editing the URL
// to contain subscription name in the scope list and the subscription
// would end up in the user's scope list whether they actually
// paid for it or not. See https://github.com/mozilla/fxa/issues/2478
if (grant['fxa-subscriptions']) {
claims['fxa-subscriptions'] = grant['fxa-subscriptions'].join(' ');
}
return {
...accessToken,
jwt_token: await exports.sign(claims),
};
};
/**
* Sign a set of claims to create a JWT access token
*
* @param {Object} claims
* @returns {Promise<JWT>}
*/
exports.sign = function sign(claims) {
return jwt.sign(claims, {
header: {
typ: HEADER_TYP,
},
});
};
/**
* Get the token ID of the JWT access token.
*
* @param {String} accessToken
* @throws `invalidToken` error if access token is invalid.
*/
exports.tokenId = async function tokenId(accessToken) {
// The access token ID is stored in the jti field of
// a JWT access token.
const payload = await exports.verify(accessToken);
if (!payload.jti) {
throw AppError.invalidToken();
}
return payload.jti;
};
/**
* Verify a JWT access token, return the payload if valid.
*
* @param {String} accessToken
* @throws `invalidToken` error if access token is invalid.
* @returns {Promise<Object>}
*/
exports.verify = async function verify(accessToken) {
let payload;
try {
payload = await jwt.verify(accessToken, {
typ: HEADER_TYP,
});
} catch (err) {
throw AppError.invalidToken();
}
if (!payload) {
throw AppError.invalidToken();
}
return payload;
};

Просмотреть файл

@ -0,0 +1,60 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const hex = require('buf').to.hex;
const hkdf = require('../../lib/crypto/hkdf');
const config = require('../../config');
const validators = require('./validators');
const PPID_ENABLED = config.get('oauthServer.ppid.enabled');
const PPID_CLIENT_IDS = new Set(
config.get('oauthServer.ppid.enabledClientIds')
);
const PPID_ROTATING_CLIENT_IDS = new Set(
config.get('oauthServer.ppid.rotatingClientIds')
);
const PPID_ROTATION_PERIOD_MS = config.get('oauthServer.ppid.rotationPeriodMS');
const PPID_SALT = config.get('oauthServer.ppid.salt');
const PPID_INFO = 'oidc ppid sub';
module.exports = async function generateSub(
userIdBuf,
clientIdBuf,
clientSeed = 0
) {
if (!Buffer.isBuffer(userIdBuf)) {
throw new Error('invalid userIdBuf');
}
if (!Buffer.isBuffer(clientIdBuf)) {
throw new Error('invalid clientIdBuf');
}
if (validators.ppidSeed.validate(clientSeed).error) {
throw new Error('invalid ppidSeed');
}
const clientIdHex = hex(clientIdBuf).toLowerCase();
const userIdHex = hex(userIdBuf).toLowerCase();
if (PPID_ENABLED && PPID_CLIENT_IDS.has(clientIdHex)) {
// Input values used in the HKDF must not contain a `.` to ensure
// collisions are not possible by manipulating the input.
// userIdHex is guaranteed to be a hex string and not contain any '.'
// clientIdHex is guaranteed to be a hex string and not contain any '.'
// clientSeed is a constrained integer between 0 and 1024. See validators.js->ppidSeed
let timeBasedContext = 0;
if (PPID_ROTATING_CLIENT_IDS.has(clientIdHex)) {
timeBasedContext = Math.floor(Date.now() / PPID_ROTATION_PERIOD_MS);
}
return hex(
await hkdf(
`${clientIdHex}.${userIdHex}.${clientSeed}.${timeBasedContext}`,
PPID_INFO,
PPID_SALT,
userIdBuf.length
)
);
} else {
return userIdHex;
}
};

Просмотреть файл

@ -0,0 +1,235 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const assert = require('assert');
const config = require('../../config');
const { jwk2pem, pem2jwk } = require('pem-jwk');
const crypto = require('crypto');
const Joi = require('joi');
const BASE64URL = /^[A-Za-z0-9-_]+$/;
const PUBLIC_KEY_SCHEMA = (exports.PUBLIC_KEY_SCHEMA = Joi.object({
kty: Joi.string()
.only('RSA')
.required(),
kid: Joi.string().required(),
n: Joi.string()
.regex(BASE64URL)
.required(),
e: Joi.string()
.regex(BASE64URL)
.required(),
alg: Joi.string()
.only('RS256')
.optional(),
use: Joi.string()
.only('sig')
.optional(),
'fxa-createdAt': Joi.number()
.integer()
.min(0)
.optional(),
}));
const PRIVATE_KEY_SCHEMA = (exports.PRIVATE_KEY_SCHEMA = Joi.object({
kty: Joi.string()
.only('RSA')
.required(),
kid: Joi.string().required(),
n: Joi.string()
.regex(BASE64URL)
.required(),
e: Joi.string()
.regex(BASE64URL)
.required(),
d: Joi.string()
.regex(BASE64URL)
.required(),
alg: Joi.string()
.only('RS256')
.optional(),
use: Joi.string()
.only('sig')
.optional(),
p: Joi.string()
.regex(BASE64URL)
.required(),
q: Joi.string()
.regex(BASE64URL)
.required(),
dp: Joi.string()
.regex(BASE64URL)
.required(),
dq: Joi.string()
.regex(BASE64URL)
.required(),
qi: Joi.string()
.regex(BASE64URL)
.required(),
'fxa-createdAt': Joi.number()
.integer()
.min(0)
.optional(),
}));
const PRIVATE_JWKS_MAP = new Map();
const PUBLIC_JWK_MAP = new Map();
const PUBLIC_PEM_MAP = new Map();
// The active signing key should be a proper RSA private key,
// and should always exist (unless we're initializing the keys for the first time).
const currentPrivJWK = config.get('oauthServer.openid.key');
if (currentPrivJWK) {
assert.strictEqual(
PRIVATE_KEY_SCHEMA.validate(currentPrivJWK).error,
null,
'openid.key must be a valid private key'
);
PRIVATE_JWKS_MAP.set(currentPrivJWK.kid, currentPrivJWK);
} else if (!config.get('oauthServer.openid.unsafelyAllowMissingActiveKey')) {
assert.fail(
'oauthServer.openid.key is missing; bailing out in a cowardly fashion...'
);
}
// The pending signing key, if present, should be a proper RSA private key
// and must be different from the active key..
const newPrivJWK = config.get('oauthServer.openid.newKey');
if (newPrivJWK) {
assert.strictEqual(
PRIVATE_KEY_SCHEMA.validate(newPrivJWK).error,
null,
'openid.newKey must be a valid private key'
);
assert.notEqual(
currentPrivJWK.kid,
newPrivJWK.kid,
'openid.key.kid must differ from openid.newKey.id'
);
PRIVATE_JWKS_MAP.set(newPrivJWK.kid, newPrivJWK);
}
// The retired signing key, if present, should be a proper RSA *public* key.
// We will never again sign anything with it, so no need to keep the private component.
const oldPubJWK = config.get('oauthServer.openid.oldKey');
if (oldPubJWK) {
assert.strictEqual(
PUBLIC_KEY_SCHEMA.validate(oldPubJWK).error,
null,
'openid.oldKey must be a valid public key'
);
assert.notEqual(
currentPrivJWK.kid,
oldPubJWK.kid,
'openid.key.kid must differ from openid.oldKey.id'
);
PRIVATE_JWKS_MAP.set(oldPubJWK.kid, oldPubJWK);
}
/**
* Extract the public portion of a key.
*
* @param {JWK} The private key.
* @returns {JWK} Its corresponding public key.
*/
exports.extractPublicKey = function extractPublicKey(key) {
// Hey, this is important. Listen up.
//
// This function pulls out only the **PUBLIC** pieces of this key.
// For RSA, that's the `e` and `n` values.
//
// BE CAREFUL IF YOU REFACTOR THIS. Thanks.
return {
kty: key.kty,
alg: key.alg || 'RS256',
kid: key.kid,
'fxa-createdAt': key['fxa-createdAt'],
use: key.use || 'sig',
n: key.n,
e: key.e,
};
};
/**
* Generate a new, random private key.
*
* @returns {JWK}
*/
exports.generatePrivateKey = function generatePrivateKey() {
const PEM_ENCODING = {
type: 'pkcs1',
format: 'pem',
};
const kp = crypto.generateKeyPairSync('rsa', {
modulusLength: 256 * 8,
publicKeyEncoding: PEM_ENCODING,
privateKeyEncoding: PEM_ENCODING,
});
// We tag our keys with their creation time, and a unique key id
// based on a hash of the public key and the timestamp. The result
// comes out like:
// {
// kid: "20170316-ebe69008"
// "fxa-createdAt": 1489716000,
// }
const now = new Date();
const pubKeyFingerprint = crypto
.createHash('sha256')
.update(kp.publicKey)
.digest('hex')
.slice(0, 8);
const privKey = Object.assign(pem2jwk(kp.privateKey), {
kid:
now
.toISOString()
.slice(0, 10)
.replace(/-/g, '') +
'-' +
pubKeyFingerprint,
alg: 'RS256',
use: 'sig',
// Timestamp to nearest hour; consumers don't need to know the precise time.
'fxa-createdAt': Math.floor(now / 1000 / 3600) * 3600,
});
return privKey;
};
/**
* Get a public PEM by `kid`.
*
* @param {String} kid of PEM to get
* @throws {Error} if no PEM found for `kid`
* @returns {JWK}
*/
exports.publicPEM = function publicPEM(kid) {
const pem = PUBLIC_PEM_MAP.get(kid);
if (!pem) {
throw new Error('PEM not found');
}
return pem;
};
PRIVATE_JWKS_MAP.forEach((privJWK, kid) => {
const publicJWK = exports.extractPublicKey(privJWK);
PUBLIC_JWK_MAP.set(kid, publicJWK);
PUBLIC_PEM_MAP.set(kid, jwk2pem(publicJWK));
});
// An array of raw public keys that can be fetched
// by remote services to locally verify.
exports.PUBLIC_KEYS = Array.from(PUBLIC_JWK_MAP.values());
// The PEM to sign with, and related details.
// This won't be present if we're unsafely allowing the module to load without
// keys property configured.
if (currentPrivJWK) {
const SIGNING_JWK = currentPrivJWK;
const SIGNING_PEM = jwk2pem(SIGNING_JWK);
exports.SIGNING_PEM = SIGNING_PEM;
exports.SIGNING_KID = SIGNING_JWK.kid;
exports.SIGNING_ALG = 'RS256';
}

Просмотреть файл

@ -0,0 +1,29 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
let mozlog = null;
module.exports = function(nameOrLog) {
if (typeof nameOrLog === 'string') {
// ignore logger labels for now.
// auth and oauth used different logging strategies
// eventually these should be consolidated under
// lib/log.js
if (mozlog) {
return mozlog.logger;
}
// main key_server must set the log
if (process.mainModule.filename.includes('key_server')) {
throw new Error('uninitialized mozlog');
}
// probably a test
mozlog = {
logger: require('mozlog')({ app: 'unknown', level: 'critical' })(
nameOrLog
),
};
return mozlog.logger;
}
mozlog = nameOrLog;
};

Просмотреть файл

@ -0,0 +1,53 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const logger = require('./')('summary');
function parsePayload(payload) {
var payloadKeys = ['INVALID_PAYLOAD_OBJECT'];
try {
// given payload object might not be a valid object
// See issue #410
payloadKeys = Object.keys(payload);
} catch (e) {
// failed to parse payload keys.
}
return payloadKeys;
}
module.exports = function summary(request, response) {
/*eslint complexity: [2, 11] */
if (request.method === 'options') {
return;
}
var payload = request.payload || {};
var query = request.query || {};
var params = request.params || {};
var auth = request.auth &&
request.auth.credentials && {
user: request.auth.credentials.user,
scope: request.auth.credentials.scope,
};
var line = {
code: response.isBoom ? response.output.statusCode : response.statusCode,
errno: response.errno || 0,
method: request.method,
path: request.path,
agent: request.headers['user-agent'],
t: Date.now() - request.info.received,
client_id: payload.client_id || query.client_id || params.client_id,
auth: auth,
payload: parsePayload(payload),
remoteAddressChain: request.app.remoteAddressChain,
};
if (line.code >= 500) {
line.stack = response.stack;
logger.error('summary', line);
} else {
logger.info('summary', line);
}
};

Просмотреть файл

@ -0,0 +1,86 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// This module contains mappings from event names to amplitude event definitions.
// A module in fxa-shared is responsible for performing the actual transformations.
//
// You can see the event taxonomy here:
//
// https://docs.google.com/spreadsheets/d/1G_8OJGOxeWXdGJ1Ugmykk33Zsl-qAQL05CONSeD4Uz4
'use strict';
const {
GROUPS,
initialize,
} = require('../../../../fxa-shared/metrics/amplitude');
const { version: VERSION } = require('../../../package.json');
const EVENTS = {
'token.created': {
group: GROUPS.activity,
event: 'access_token_created',
},
'verify.success': {
group: GROUPS.activity,
event: 'access_token_checked',
},
};
const FUZZY_EVENTS = new Map([]);
function sane(event) {
if (!event) {
return false;
}
const props = event.event_properties;
const excluded =
(props.service === 'fennec-stage' &&
props.oauth_client_id === '3332a18d142636cb') ||
(props.service === 'firefox-desktop' &&
props.oauth_client_id === '5882386c6d801776') ||
(props.service === 'firefox-ios' &&
props.oauth_client_id === '1b1a3e44c54fbb58');
return !excluded;
}
module.exports = (log, config) => {
if (!log || !config.oauthServer.clientIdToServiceNames) {
throw new TypeError('Missing argument');
}
const transformEvent = initialize(
config.oauthServer.clientIdToServiceNames,
EVENTS,
FUZZY_EVENTS
);
return function receiveEvent(event, data) {
if (!event || !data) {
log.error('amplitude.badArgument', { err: 'Bad argument', event });
return;
}
const eventData = Object.assign(
{},
{
uid: data.uid,
service: data.service,
version: VERSION,
}
);
const amplitudeEvent = transformEvent(
{
type: event,
time: data.time || Date.now(),
},
eventData
);
if (sane(amplitudeEvent)) {
log.info('amplitudeEvent', amplitudeEvent);
}
};
};

Просмотреть файл

@ -0,0 +1,243 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const hex = require('buf').to.hex;
const Joi = require('joi');
const URI = require('urijs');
const AppError = require('../error');
const config = require('../../../config');
const db = require('../db');
const logger = require('../logging')('routes.authorization');
const validators = require('../validators');
const { validateRequestedGrant, generateTokens } = require('../grant');
const verifyAssertion = require('../assertion');
const RESPONSE_TYPE_CODE = 'code';
const RESPONSE_TYPE_TOKEN = 'token';
const ACCESS_TYPE_ONLINE = 'online';
const ACCESS_TYPE_OFFLINE = 'offline';
const PKCE_SHA256_CHALLENGE_METHOD = 'S256'; // This server only supports S256 PKCE, no 'plain'
const PKCE_CODE_CHALLENGE_LENGTH = 43;
const MAX_TTL_S = config.get('oauthServer.expiration.accessToken') / 1000;
const DISABLED_CLIENTS = new Set(config.get('oauthServer.disabledClients'));
var ALLOWED_SCHEMES = ['https'];
if (config.get('oauthServer.allowHttpRedirects') === true) {
// http scheme used when developing OAuth clients
ALLOWED_SCHEMES.push('http');
}
function isLocalHost(url) {
var host = new URI(url).hostname();
return host === 'localhost' || host === '127.0.0.1';
}
module.exports = {
validate: {
payload: {
client_id: validators.clientId,
assertion: validators.assertion.required(),
redirect_uri: Joi.string()
.max(256)
// uri validation ref: https://github.com/hapijs/joi/blob/master/API.md#stringurioptions
.uri({
scheme: ALLOWED_SCHEMES,
}),
scope: validators.scope.required(),
response_type: Joi.string()
.valid(RESPONSE_TYPE_CODE, RESPONSE_TYPE_TOKEN)
.default(RESPONSE_TYPE_CODE),
state: Joi.string()
.max(256)
.when('response_type', {
is: RESPONSE_TYPE_TOKEN,
then: Joi.optional(),
otherwise: Joi.required(),
}),
ttl: Joi.number()
.positive()
.max(MAX_TTL_S)
.default(MAX_TTL_S)
.when('response_type', {
is: RESPONSE_TYPE_TOKEN,
then: Joi.optional(),
otherwise: Joi.forbidden(),
}),
access_type: Joi.string()
.valid(ACCESS_TYPE_OFFLINE, ACCESS_TYPE_ONLINE)
.default(ACCESS_TYPE_ONLINE)
.optional(),
code_challenge_method: Joi.string()
.valid(PKCE_SHA256_CHALLENGE_METHOD)
.when('response_type', {
is: RESPONSE_TYPE_CODE,
then: Joi.optional(),
otherwise: Joi.forbidden(),
})
.when('code_challenge', {
is: Joi.string().required(),
then: Joi.required(),
}),
code_challenge: Joi.string()
.length(PKCE_CODE_CHALLENGE_LENGTH)
.when('response_type', {
is: RESPONSE_TYPE_CODE,
then: Joi.optional(),
otherwise: Joi.forbidden(),
}),
keys_jwe: validators.jwe.when('response_type', {
is: RESPONSE_TYPE_CODE,
then: Joi.optional(),
otherwise: Joi.forbidden(),
}),
acr_values: Joi.string()
.max(256)
.optional()
.allow(null),
resource: validators.resourceUrl.when('response_type', {
is: RESPONSE_TYPE_TOKEN,
then: Joi.optional(),
otherwise: Joi.forbidden(),
}),
},
},
response: {
schema: Joi.object()
.keys({
redirect: Joi.string(),
code: Joi.string(),
state: Joi.string(),
access_token: validators.accessToken,
token_type: Joi.string().valid('bearer'),
scope: Joi.string().allow(''),
auth_at: Joi.number(),
expires_in: Joi.number(),
})
.with('access_token', ['token_type', 'scope', 'auth_at', 'expires_in'])
.with('code', ['state', 'redirect'])
.without('code', ['access_token']),
},
handler: async function authorizationEndpoint(req) {
// Refuse to generate new codes or tokens for disabled clients.
if (DISABLED_CLIENTS.has(req.payload.client_id)) {
throw AppError.disabledClient(req.payload.client_id);
}
const claims = await verifyAssertion(req.payload.assertion);
const client = await db.getClient(
Buffer.from(req.payload.client_id, 'hex')
);
if (!client) {
logger.debug('notFound', { id: req.payload.client_id });
throw AppError.unknownClient(req.payload.client_id);
}
validateClientDetails(client, req.payload);
const grant = await validateRequestedGrant(claims, client, req.payload);
switch (req.payload.response_type) {
case RESPONSE_TYPE_CODE:
return await generateAuthorizationCode(client, req.payload, grant);
case RESPONSE_TYPE_TOKEN:
return await generateImplicitGrant(client, req.payload, grant);
default:
// Joi validation means this should never happen.
logger.critical('joi.response_type', {
response_type: req.payload.response_type,
});
throw AppError.invalidResponseType();
}
},
};
async function generateAuthorizationCode(client, payload, grant) {
// Clients must use PKCE if and only if they are a pubic client.
if (client.publicClient) {
if (!payload.code_challenge_method || !payload.code_challenge) {
logger.info('client.missingPkceParameters');
throw AppError.missingPkceParameters();
}
} else {
if (payload.code_challenge_method || payload.code_challenge) {
logger.info('client.notPublicClient');
throw AppError.notPublicClient({ id: payload.client_id });
}
}
const state = payload.state;
let code = await db.generateCode(
Object.assign(grant, {
codeChallengeMethod: payload.code_challenge_method,
codeChallenge: payload.code_challenge,
sessionTokenId:
grant.sessionTokenId && Buffer.from(grant.sessionTokenId, 'hex'),
})
);
code = hex(code);
const redirect = URI(payload.redirect_uri).addQuery({ code, state });
return {
code,
state,
redirect: String(redirect),
};
}
// N.B. We do not correctly implement the "implicit grant" flow from
// RFC6749 which defines `response_type=token`. Instead we have a
// privileged set of clients that use `response_type=token` for something
// approximating the "resource owner password grant" flow, using an identity
// assertion to just directly grant tokens for their own use. Known current
// users of this functinality include:
//
// * Firefox Desktop, for getting "profile"-scoped tokens to access profile data
// * Firefox for Android, for getting "profile"-scoped tokens to access profile data
// * Firefox for iOS, for getting "profile"-scoped tokens to access profile data
//
// New clients should not do this, and should instead of `grant_type=fxa-credentials`
// on the /token endpoint.
//
// This route is kept for backwards-compatibility only.
async function generateImplicitGrant(client, payload, grant) {
if (!client.canGrant) {
logger.warn('grantType.notAllowed', {
id: hex(client.id),
grant_type: 'fxa-credentials',
});
throw AppError.invalidResponseType();
}
return generateTokens({
...grant,
resource: payload.resource,
ttl: payload.ttl,
});
}
function validateClientDetails(client, payload) {
// Clients must use a single specific redirect_uri,
// but they're allowed to not provide one and have us fill it in automatically.
payload.redirect_uri = payload.redirect_uri || client.redirectUri;
if (payload.redirect_uri !== client.redirectUri) {
logger.debug('redirect.mismatch', {
param: payload.redirect_uri,
registered: client.redirectUri,
});
if (
config.get('oauthServer.localRedirects') &&
isLocalHost(payload.redirect_uri)
) {
logger.debug('redirect.local', { uri: payload.redirect_uri });
} else {
throw AppError.incorrectRedirect(payload.redirect_uri);
}
}
}

Просмотреть файл

@ -0,0 +1,35 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const validators = require('../../validators');
const db = require('../../db');
const error = require('../../error');
const verifyAssertion = require('../../assertion');
module.exports = {
validate: {
payload: {
client_id: validators.clientId,
refresh_token_id: validators.token.optional(),
assertion: validators.assertion,
},
},
handler: async function(req) {
const claims = await verifyAssertion(req.payload.assertion);
if (req.payload.refresh_token_id) {
if (
!(await db.deleteClientRefreshToken(
req.payload.refresh_token_id,
req.payload.client_id,
claims.uid
))
) {
throw error.unknownToken();
}
} else {
await db.deleteClientAuthorization(req.payload.client_id, claims.uid);
}
return {};
},
};

Просмотреть файл

@ -0,0 +1,140 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const hex = require('buf').to.hex;
const Joi = require('joi');
const db = require('../../db');
const validators = require('../../validators');
const verifyAssertion = require('../../assertion');
const ScopeSet = require('../../../../../fxa-shared').oauth.scopes;
// Helper function to render each returned record in the expected form.
function serialize(clientIdHex, token, acceptLanguage) {
const createdTime = token.createdAt.getTime();
const lastAccessTime = token.lastUsedAt.getTime();
return {
client_id: clientIdHex,
refresh_token_id: token.refreshTokenId
? hex(token.refreshTokenId)
: undefined,
client_name: token.clientName,
created_time: createdTime,
last_access_time: lastAccessTime,
// Sort the scopes alphabetically, for consistent output.
scope: token.scope.getScopeValues().sort(),
};
}
module.exports = {
validate: {
payload: {
assertion: validators.assertion.required(),
},
},
response: {
schema: Joi.array().items(
Joi.object({
client_id: validators.clientId,
refresh_token_id: validators.token.optional(),
client_name: Joi.string().required(),
created_time: Joi.number()
.min(0)
.required(),
last_access_time: Joi.number()
.min(0)
.required()
.allow(null),
scope: Joi.array()
.items(Joi.string())
.required(),
})
),
},
handler: async function(req) {
const claims = await verifyAssertion(req.payload.assertion);
const authorizedClients = [];
// First, enumerate all the refresh tokens.
// Each of these is a separate instance of an authorized client
// and should be displayed to the user as such. Nice and simple!
const seenClientIds = new Set();
for (const token of await db.getRefreshTokensByUid(claims.uid)) {
const clientId = hex(token.clientId);
authorizedClients.push(
serialize(clientId, token, req.headers['accept-language'])
);
seenClientIds.add(clientId);
}
// Next, enumerate all the access tokens. In the interests of giving the user a
// complete-yet-comprehensible list of all the things attached to their account,
// we want to:
//
// 1. Show a single unified record for any client that is not using refresh tokens.
// 2. Avoid showing access tokens for `canGrant` clients; such clients will always
// hold some other sort of token, and we don't want them to appear in the list twice.
const accessTokenRecordsByClientId = new Map();
for (const token of await db.getAccessTokensByUid(claims.uid)) {
const clientId = hex(token.clientId);
if (!seenClientIds.has(clientId) && !token.clientCanGrant) {
let record = accessTokenRecordsByClientId.get(clientId);
if (typeof record === 'undefined') {
record = {
clientId,
clientName: token.clientName,
createdAt: token.createdAt,
lastUsedAt: token.createdAt,
scope: ScopeSet.fromArray([]),
};
accessTokenRecordsByClientId.set(clientId, record);
}
// Merge details of all access tokens into a single record.
record.scope.add(token.scope);
if (token.createdAt < record.createdAt) {
record.createdAt = token.createdAt;
}
if (record.lastUsedAt < token.createdAt) {
record.lastUsedAt = token.createdAt;
}
}
}
for (const [clientId, record] of accessTokenRecordsByClientId.entries()) {
authorizedClients.push(
serialize(clientId, record, req.headers['accept-language'])
);
}
// Sort the final list first by last_access_time, then by client_name, then by created_time.
authorizedClients.sort(function(a, b) {
if (b.last_access_time > a.last_access_time) {
return 1;
}
if (b.last_access_time < a.last_access_time) {
return -1;
}
if (a.client_name > b.client_name) {
return 1;
}
if (a.client_name < b.client_name) {
return -1;
}
if (a.created_time > b.created_time) {
return 1;
}
if (a.created_time < b.created_time) {
return -1;
}
// To help provide a deterministic result order to simplify testing, also sort of scope values.
if (a.scope > b.scope) {
return 1;
}
if (a.scope < b.scope) {
return -1;
}
return 0;
});
return authorizedClients;
},
};

Просмотреть файл

@ -0,0 +1,21 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const db = require('../../db');
const SCOPE_CLIENT_WRITE = require('../../auth_bearer').SCOPE_CLIENT_WRITE;
module.exports = {
auth: {
strategy: 'authBearer',
scope: SCOPE_CLIENT_WRITE.getImplicantValues(),
},
handler: async function activeServices(req) {
var clientId = req.params.client_id;
return db
.deleteClientAuthorization(clientId, req.auth.credentials.user)
.then(function() {
return {};
});
},
};

Просмотреть файл

@ -0,0 +1,46 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const hex = require('buf').to.hex;
const config = require('../../../../config');
const db = require('../../db');
const SCOPE_CLIENT_WRITE = require('../../auth_bearer').SCOPE_CLIENT_WRITE;
const localizeTimestamp = require('../../../../../fxa-shared').l10n.localizeTimestamp(
{
supportedLanguages: config.get('oauthServer.i18n.supportedLanguages'),
defaultLanguage: config.get('oauthServer.i18n.defaultLanguage'),
}
);
function serialize(client, acceptLanguage) {
var lastAccessTime = client.lastAccessTime.getTime();
var lastAccessTimeFormatted = localizeTimestamp.format(
lastAccessTime,
acceptLanguage
);
return {
name: client.name,
id: hex(client.id),
lastAccessTime: lastAccessTime,
lastAccessTimeFormatted: lastAccessTimeFormatted,
scope: client.scope,
};
}
module.exports = {
auth: {
strategy: 'authBearer',
scope: SCOPE_CLIENT_WRITE.getImplicantValues(),
},
handler: async function activeServices(req) {
return db
.getActiveClientsByUid(req.auth.credentials.user)
.then(function(clients) {
return clients.map(function(client) {
return serialize(client, req.headers['accept-language']);
});
});
},
};

Просмотреть файл

@ -0,0 +1,35 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const auth = require('../../auth_client_management');
const db = require('../../db');
const validators = require('../../validators');
const AppError = require('../../error');
module.exports = {
auth: {
strategy: auth.AUTH_STRATEGY,
scope: auth.SCOPE_CLIENT_MANAGEMENT.getImplicantValues(),
},
validate: {
params: {
client_id: validators.clientId,
},
},
handler: async function clientDeleteEndpoint(req, h) {
var email = req.auth.credentials.email;
var clientId = req.params.client_id;
return db.developerOwnsClient(email, clientId).then(
function() {
return db.removeClient(clientId).then(function() {
return h.response({}).code(204);
});
},
function() {
throw new AppError.unauthorized('Illegal Developer');
}
);
},
};

Просмотреть файл

@ -0,0 +1,50 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const hex = require('buf').to.hex;
const Joi = require('joi');
const AppError = require('../../error');
const db = require('../../db');
const logger = require('../../logging')('routes.client.get');
const validators = require('../../validators');
module.exports = {
validate: {
params: {
client_id: validators.clientId,
},
},
response: {
schema: {
id: validators.clientId,
name: Joi.string().required(),
trusted: Joi.boolean().required(),
image_uri: Joi.any(),
redirect_uri: Joi.string()
.required()
.allow(''),
},
},
handler: async function requestInfoEndpoint(req) {
const params = req.params;
return db
.getClient(Buffer.from(params.client_id, 'hex'))
.then(function(client) {
if (!client) {
logger.debug('notFound', { id: params.client_id });
throw AppError.unknownClient(params.client_id);
} else {
return {
id: hex(client.id),
name: client.name,
trusted: client.trusted,
image_uri: client.imageUri,
redirect_uri: client.redirectUri,
};
}
});
},
};

Просмотреть файл

@ -0,0 +1,53 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const Joi = require('joi');
const hex = require('buf').to.hex;
const auth = require('../../auth_client_management');
const db = require('../../db');
const validators = require('../../validators');
function serialize(client) {
return {
id: hex(client.id),
name: client.name,
image_uri: client.imageUri,
redirect_uri: client.redirectUri,
can_grant: client.canGrant,
trusted: client.trusted,
};
}
module.exports = {
auth: {
strategy: auth.AUTH_STRATEGY,
scope: auth.SCOPE_CLIENT_MANAGEMENT.getImplicantValues(),
},
response: {
schema: {
clients: Joi.array().items(
Joi.object().keys({
id: validators.clientId,
name: Joi.string().required(),
image_uri: Joi.string().allow(''),
redirect_uri: Joi.string()
.allow('')
.required(),
can_grant: Joi.boolean().required(),
trusted: Joi.boolean().required(),
})
),
},
},
handler: async function listEndpoint(req) {
const developerEmail = req.auth.credentials.email;
return db.getClients(developerEmail).then(function(clients) {
return {
clients: clients.map(serialize),
};
});
},
};

Просмотреть файл

@ -0,0 +1,90 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const Joi = require('joi');
const auth = require('../../auth_client_management');
const db = require('../../db');
const encrypt = require('../../encrypt');
const hex = require('buf').to.hex;
const unique = require('../../unique');
const validators = require('../../validators');
const AppError = require('../../error');
module.exports = {
auth: {
strategy: auth.AUTH_STRATEGY,
scope: auth.SCOPE_CLIENT_MANAGEMENT.getImplicantValues(),
},
validate: {
payload: {
name: Joi.string()
.max(256)
.required(),
image_uri: Joi.string()
.max(256)
.allow(''),
redirect_uri: Joi.string()
.max(256)
.required(),
can_grant: Joi.boolean(),
trusted: Joi.boolean(),
},
},
response: {
schema: {
id: validators.clientId,
secret: validators.clientSecret,
name: Joi.string().required(),
image_uri: Joi.string().allow(''),
redirect_uri: Joi.string().required(),
can_grant: Joi.boolean().required(),
trusted: Joi.boolean().required(),
},
},
handler: async function registerEndpoint(req, h) {
var payload = req.payload;
var secret = unique.secret();
var client = {
id: unique.id(),
hashedSecret: encrypt.hash(secret),
name: payload.name,
redirectUri: payload.redirect_uri,
imageUri: payload.image_uri || '',
canGrant: !!payload.can_grant,
trusted: !!payload.trusted,
};
var developerEmail = req.auth.credentials.email;
var developerId = null;
return db
.getDeveloper(developerEmail)
.then(function(developer) {
// must be a developer to register clients
if (!developer) {
throw AppError.unauthorized('Illegal Developer');
}
developerId = developer.developerId;
return db.registerClient(client);
})
.then(function() {
return db.registerClientDeveloper(developerId, hex(client.id));
})
.then(function() {
return h
.response({
id: hex(client.id),
secret: hex(secret),
name: client.name,
redirect_uri: client.redirectUri,
image_uri: client.imageUri,
can_grant: client.canGrant,
trusted: client.trusted,
})
.code(201);
});
},
};

Просмотреть файл

@ -0,0 +1,53 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const buf = require('buf').hex;
const Joi = require('joi');
const auth = require('../../auth_client_management');
const db = require('../../db');
const validators = require('../../validators');
const AppError = require('../../error');
module.exports = {
auth: {
strategy: auth.AUTH_STRATEGY,
scope: auth.SCOPE_CLIENT_MANAGEMENT.getImplicantValues(),
},
validate: {
params: {
client_id: validators.clientId,
},
payload: {
name: Joi.string().max(256),
image_uri: Joi.string().max(256),
redirect_uri: Joi.string().max(256),
can_grant: Joi.boolean(),
},
},
handler: async function updateClientEndpoint(req, reply) {
const clientId = req.params.client_id;
const payload = req.payload;
const email = req.auth.credentials.email;
return db.developerOwnsClient(email, clientId).then(
function() {
return db
.updateClient({
id: buf(clientId),
name: payload.name,
redirectUri: payload.redirect_uri,
imageUri: payload.image_uri,
canGrant: payload.can_grant,
})
.then(function() {
return {};
});
},
function() {
throw AppError.unauthorized('Illegal Developer');
}
);
},
};

Просмотреть файл

@ -0,0 +1,19 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const config = require('../../../config');
const CONFIG = {
browserid: {
issuer: config.get('oauthServer.browserid.issuer'),
verificationUrl: config.get('oauthServer.browserid.verificationUrl'),
},
contentUrl: config.get('oauthServer.contentUrl'),
};
module.exports = {
handler: async function configRoute() {
return CONFIG;
},
};

Просмотреть файл

@ -0,0 +1,79 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const crypto = require('crypto');
const Joi = require('joi');
const hex = require('buf').to.hex;
const AppError = require('../error');
const db = require('../db');
const encrypt = require('../encrypt');
const validators = require('../validators');
const logger = require('../logging')('routes.destroy');
const { getTokenId } = require('../token');
const { authenticateClient, clientAuthValidators } = require('../client');
/*jshint camelcase: false*/
module.exports = {
validate: {
headers: clientAuthValidators.headers,
payload: Joi.object()
.keys({
client_id: clientAuthValidators.clientId.optional(),
// For historical reasons, we accept and ignore a client_secret if one
// is provided without a corresponding client_id.
// https://github.com/mozilla/fxa-oauth-server/pull/198
client_secret: clientAuthValidators.clientSecret.allow('').optional(),
access_token: validators.accessToken,
refresh_token: validators.token,
refresh_token_id: validators.token,
})
.rename('token', 'access_token')
.xor('access_token', 'refresh_token', 'refresh_token_id'),
},
handler: async function destroyToken(req) {
var token;
var getToken;
var removeToken;
// If client credentials were provided, validate them.
// For legacy reasons it is possible to call this endpoint without credentials.
let client = null;
if (req.headers.authorization || req.payload.client_id) {
client = await authenticateClient(req.headers, req.payload);
}
if (req.payload.access_token) {
getToken = 'getAccessToken';
removeToken = 'removeAccessToken';
token = await getTokenId(req.payload.access_token);
} else {
getToken = 'getRefreshToken';
removeToken = 'removeRefreshToken';
if (req.payload.refresh_token_id) {
token = req.payload.refresh_token_id;
} else {
token = encrypt.hash(req.payload.refresh_token);
}
}
const tokObj = await db[getToken](token);
if (!tokObj) {
throw AppError.invalidToken();
}
if (client && !crypto.timingSafeEqual(tokObj.clientId, client.id)) {
throw AppError.invalidToken();
} else if (!client && req.payload.hasOwnProperty('client_secret')) {
// Log a warning if legacy client_secret is provided, so we can
// measure whether it's safe to remove this behaviour.
logger.warn('destroy.unexpectedClientSecret', {
client_id: hex(tokObj.clientId),
});
}
await db[removeToken](token);
return {};
},
};

Просмотреть файл

@ -0,0 +1,36 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const auth = require('../../auth_client_management');
const db = require('../../db');
const hex = require('buf').to.hex;
function developerResponse(developer) {
return {
developerId: hex(developer.developerId),
email: developer.email,
createdAt: developer.createdAt,
};
}
module.exports = {
auth: {
strategy: auth.AUTH_STRATEGY,
scope: auth.SCOPE_CLIENT_MANAGEMENT.getImplicantValues(),
},
handler: async function activateRegistration(req) {
const email = req.auth.credentials.email;
return db
.getDeveloper(email)
.then(function(developer) {
if (developer) {
return developer;
} else {
return db.activateDeveloper(email);
}
})
.then(developerResponse);
},
};

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше